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
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/296-each-date-can-carry-a-short-paperwork-ch → main 6 commits fork 2 threads · 2 unresolved
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) | ||
| 106 | 106 | ask the same question, and the deal view, **P** and the reminders list show the |
| 107 | 107 | answer. The sample parties carry a mix. |
| 108 | 108 | |
| 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 | ||
| 109 | 128 | ## Finding a deal |
| 110 | 129 | |
| 111 | 130 | 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 @@ | ||
| 1176 | 1176 | ], |
| 1177 | 1177 | "description": "Who recorded the waiver." |
| 1178 | 1178 | }, |
| 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 | }, | |
| 1179 | 1209 | "time": { |
| 1180 | 1210 | "type": [ |
| 1181 | 1211 | "string", |
| @@ -3450,6 +3480,36 @@ | ||
| 3450 | 3480 | ], |
| 3451 | 3481 | "description": "Who recorded the waiver." |
| 3452 | 3482 | }, |
| 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 | }, | |
| 3453 | 3513 | "time": { |
| 3454 | 3514 | "type": [ |
| 3455 | 3515 | "string", |
docs/TECHNICAL.md +4
| @@ -401,6 +401,10 @@ columns, same rules, validated by `src/appointment.ts` through `closing.ts`). `s | ||
| 401 | 401 | (any other kind of date is 422 with `field: "time"`). They appear on the deal page, timeline, broker |
| 402 | 402 | 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 |
| 403 | 403 | 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 | ||
| 404 | 408 | ### Side |
| 405 | 409 | |
| 406 | 410 | `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 | ||
| 10 | 10 | - `.timeline-row` — one date; never split across printed pages. |
| 11 | 11 | - `.timeline-date`, `.timeline-label`, `.timeline-owner`, `.timeline-status` — its four facts; the status is words first, with a mark. |
| 12 | 12 | - `.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>> = { | ||
| 27 | 27 | "date.lead": "changed when a date's reminder goes out", |
| 28 | 28 | "date.add": "added a date", |
| 29 | 29 | "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", | |
| 30 | 33 | "deal.amend": "amended the contract dates", |
| 31 | 34 | "deal.fell": "marked a deal fell through", |
| 32 | 35 | "deal.reopen": "brought a deal that fell through back onto the clock", |
src/db.ts +8
| @@ -576,6 +576,14 @@ export const migrations: string[] = [ | ||
| 576 | 576 | `ALTER TABLE party ADD COLUMN reach TEXT NOT NULL DEFAULT '' CHECK (reach IN ('', 'text', 'email', 'call'))`, |
| 577 | 577 | // The day the agent chose for a held reminder to come back; NULL for a plain "tomorrow" hold. |
| 578 | 578 | `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)`, | |
| 579 | 587 | ]; |
| 580 | 588 | |
| 581 | 589 | 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 " | ||
| 42 | 42 | import { audited, conflict, fail, ok, refuse, type Result } from "./result.ts"; |
| 43 | 43 | import { appointmentClashes, closingConflicts, type Clash, type ClosingConflict } from "./overlap.ts"; |
| 44 | 44 | import type { Who } from "./who.ts"; |
| 45 | import type { Paper } from "./papers.ts"; | |
| 45 | 46 | |
| 46 | 47 | export interface Deal { |
| 47 | 48 | readonly id: number; |
| @@ -183,6 +184,8 @@ export interface Deadline { | ||
| 183 | 184 | readonly place: string | null; |
| 184 | 185 | } |
| 185 | 186 | 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[]; | |
| 186 | 189 | /** The day it was marked done, by the app's own calendar (`today()`); null while open or after a reopen. */ |
| 187 | 190 | readonly done_on: string | null; |
| 188 | 191 | /** 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 => | ||
| 296 | 299 | interface Children { |
| 297 | 300 | deadlines: Deadline[]; |
| 298 | 301 | moves: Move[]; |
| 302 | papers: Paper[]; | |
| 299 | 303 | parties: Party[]; |
| 300 | 304 | sent: SentRow[]; |
| 301 | 305 | declined: Declined[]; |
| @@ -309,9 +313,12 @@ function withHref(m: SentRow, parties: readonly Party[], zone: string): Sent { | ||
| 309 | 313 | return { ...m, sent_on: dayOfStamp(zone, m.sent_at), href: hrefs[0] ?? null, hrefs }; |
| 310 | 314 | } |
| 311 | 315 | |
| 316 | const NO_PAPERS: readonly Paper[] = []; | |
| 317 | ||
| 312 | 318 | /** One deal assembled from its rows. */ |
| 313 | 319 | function assemble(deal: Deal, rows: Children, zone: string): DealView { |
| 314 | 320 | const moves = rows.moves; |
| 321 | const papers = rows.papers.length === 0 ? undefined : byDeal(rows.papers, (p) => p.deadline_id); | |
| 315 | 322 | return { |
| 316 | 323 | ...deal, |
| 317 | 324 | zone, |
| @@ -326,6 +333,7 @@ function assemble(deal: Deal, rows: Children, zone: string): DealView { | ||
| 326 | 333 | waived_on: d.waived_at === null ? null : dayOfStamp(zone, d.waived_at), |
| 327 | 334 | owes_changed: owes_from === null || owes_by === null || owes_on === null ? null : { from: owes_from, by: owes_by, on: owes_on }, |
| 328 | 335 | moves: moves.filter((m) => m.deadline_id === d.id), |
| 336 | papers: papers?.get(d.id) ?? NO_PAPERS, | |
| 329 | 337 | nonBusinessDay: isOpen(d) && !isBusinessDay(d.due) ? { next: nextBusinessDay(d.due) } : null, |
| 330 | 338 | clashes: [], |
| 331 | 339 | })), |
| @@ -366,6 +374,9 @@ function view(db: Database, deal: Deal): DealView { | ||
| 366 | 374 | "SELECT m.* FROM deadline_move m JOIN deadline d ON d.id = m.deadline_id WHERE d.deal_id = ?1 ORDER BY m.id", |
| 367 | 375 | ) |
| 368 | 376 | .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), | |
| 369 | 380 | parties: db |
| 370 | 381 | .query<Party, [number]>( |
| 371 | 382 | "SELECT * FROM party WHERE deal_id = ?1 ORDER BY id", |
| @@ -422,6 +433,10 @@ function viewMany(db: Database, where: string, params: string[]): DealView[] { | ||
| 422 | 433 | .all(...params), |
| 423 | 434 | (r) => dealOf.get(r.deadline_id)!, |
| 424 | 435 | ); |
| 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 | ); | |
| 425 | 440 | const parties = byDeal( |
| 426 | 441 | db.query<Party, string[]>(`SELECT * FROM party WHERE ${of} ORDER BY id`).all(...params), |
| 427 | 442 | (r) => r.deal_id, |
| @@ -445,6 +460,7 @@ function viewMany(db: Database, where: string, params: string[]): DealView[] { | ||
| 445 | 460 | assemble(deal, { |
| 446 | 461 | deadlines: deadlines.get(deal.id) ?? [], |
| 447 | 462 | moves: moves.get(deal.id) ?? [], |
| 463 | papers: papers.get(deal.id) ?? [], | |
| 448 | 464 | parties: parties.get(deal.id) ?? [], |
| 449 | 465 | sent: sent.get(deal.id) ?? [], |
| 450 | 466 | declined: declined.get(deal.id) ?? [], |
| @@ -861,7 +877,7 @@ function remove(db: Database, who: Who, input: { id: number }): Result<{ id: num | ||
| 861 | 877 | // ---- a deal's dates, parties and reminders: each an update of the deal ---- |
| 862 | 878 | |
| 863 | 879 | /** 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 }>( | |
| 865 | 881 | db: Database, |
| 866 | 882 | who: Who, |
| 867 | 883 | what: string, |
| @@ -874,7 +890,7 @@ function within<T extends { readonly id: number }>( | ||
| 874 | 890 | } |
| 875 | 891 | |
| 876 | 892 | /** The deadline and its deal, when `who` may change the deal. */ |
| 877 | function deadlineFor( | |
| 893 | export function deadlineFor( | |
| 878 | 894 | db: Database, |
| 879 | 895 | who: Who, |
| 880 | 896 | id: number, |
src/export.ts +1
| @@ -54,6 +54,7 @@ const dealOf = (deal: DealView): Record<string, unknown> => ({ | ||
| 54 | 54 | note: d.note, |
| 55 | 55 | time: d.time, |
| 56 | 56 | place: d.place, |
| 57 | papers: d.papers.map((p) => ({ name: p.name, in: p.in_at })), | |
| 57 | 58 | moves: d.moves.map((m) => ({ from: m.from_due, to: m.to_due, reason: m.reason, by: m.by_name, at: m.at })), |
| 58 | 59 | })), |
| 59 | 60 | 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", ( | ||
| 78 | 78 | const old = new Database(":memory:"); |
| 79 | 79 | migrate(old); |
| 80 | 80 | 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); | |
| 82 | 83 | 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')"); |
| 83 | 84 | migrate(old); |
| 84 | 85 | 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. | ||
| 16 | 16 | import { closingPlace, closingTime } from "./closing.ts"; |
| 17 | 17 | import { NOTE_MAX, REASON_MAX, insertDeal, line, partyText, plan, sameAddress, type NewDeal, type Plan, type Reach } from "./deals.ts"; |
| 18 | 18 | import { EXPORT_MAX, EXPORT_VERSION } from "./export.ts"; |
| 19 | import { PAPERS_MAX, PAPER_MAX, insertPaper, paperName } from "./papers.ts"; | |
| 19 | 20 | import { emit } from "./live.ts"; |
| 20 | 21 | import { audited, fail, ok, refuse as denied, type Result } from "./result.ts"; |
| 21 | 22 | import type { Who } from "./who.ts"; |
| @@ -44,6 +45,7 @@ interface DateIn { | ||
| 44 | 45 | done: string | null; |
| 45 | 46 | waived: string | null; |
| 46 | 47 | waivedBy: string | null; |
| 48 | papers: { name: string; in: string | null }[]; | |
| 47 | 49 | moves: Move[]; |
| 48 | 50 | } |
| 49 | 51 | interface PartyIn { |
| @@ -123,6 +125,17 @@ function readDeal(raw: unknown, n: number): Result<DealIn> { | ||
| 123 | 125 | if (waived === undefined) return refuse(n, `date ${i + 1} (${label}) has a bad waived time.`, `${at}.waived`); |
| 124 | 126 | if (done !== null && waived !== null) return refuse(n, `date ${i + 1} (${label}) is both done and waived.`, `${at}.waived`); |
| 125 | 127 | 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 | } | |
| 126 | 139 | const movesRaw = list(o.moves, MOVES_MAX, `${at}.moves`); |
| 127 | 140 | if (!Array.isArray(movesRaw)) return movesRaw; |
| 128 | 141 | const moves: Move[] = []; |
| @@ -135,7 +148,7 @@ function readDeal(raw: unknown, n: number): Result<DealIn> { | ||
| 135 | 148 | 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}]`); |
| 136 | 149 | moves.push({ from: m.from, to: m.to, reason, by, at: when }); |
| 137 | 150 | } |
| 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 }); | |
| 139 | 152 | } |
| 140 | 153 | |
| 141 | 154 | const closing = dates.find((x) => x.kind === "closing"); |
| @@ -232,6 +245,7 @@ function write(db: Database, who: Who & { role: "user" | "admin" }, deals: reado | ||
| 232 | 245 | "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", |
| 233 | 246 | ) |
| 234 | 247 | .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); | |
| 235 | 249 | for (const m of x.moves) |
| 236 | 250 | 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); |
| 237 | 251 | } |
src/migrations.pin.json +2 −1
| @@ -59,5 +59,6 @@ | ||
| 59 | 59 | "c5df0d263e04d4e07f9df383beb57f033966af540bb228d9bbbd7b57f3620408", |
| 60 | 60 | "4a52d9de76ede6d34d831d9bc6fc1d3d3a501a5d3652bf63695a9de49f2a7d39", |
| 61 | 61 | "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3", |
| 62 | "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5" | |
| 62 | "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5", | |
| 63 | "c82fe9b3aea33d99a2c65ffb4179901d5aecde6b2ef759531b94ff12035a719a" | |
| 63 | 64 | ] |
src/migrations.test.ts +1 −1
| @@ -13,7 +13,7 @@ test("every migration is pinned by position; a pull may only append", () => { | ||
| 13 | 13 | }); |
| 14 | 14 | |
| 15 | 15 | 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)); | |
| 17 | 17 | const db = new Database(":memory:"); |
| 18 | 18 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"); |
| 19 | 19 | before.forEach((sql, i) => { |
src/pages/deal.ts +2 −1
| @@ -22,6 +22,7 @@ import { confirmRemove } from "./confirm.ts"; | ||
| 22 | 22 | import { layout } from "./layout.ts"; |
| 23 | 23 | import { sideOf } from "../side.ts"; |
| 24 | 24 | import { dealSideLabel, sideLabel, sideSelect } from "./side.ts"; |
| 25 | import { papersLine } from "../papers.ts"; | |
| 25 | 26 | import { progressLine } from "./progress.ts"; |
| 26 | 27 | import { appointmentForm, appointmentText, type AppointmentProblem } from "./appointment.ts"; |
| 27 | 28 | 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: { | ||
| 301 | 302 | <div> |
| 302 | 303 | <p>${badge(d, on)} <b>${escape(d.label)}</b></p> |
| 303 | 304 | <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>`} | |
| 305 | 306 | <p class="small">${owedBy(deal, d)}</p> |
| 306 | 307 | ${owesChangedLine(d)} |
| 307 | 308 | ${noteLine(d)} |
src/pages/sheet.ts +43 −1
| @@ -15,6 +15,7 @@ import { labelOf, NO_CONTACT, outboxes } from "../outbox.ts"; | ||
| 15 | 15 | import { conflictLine } from "../order.ts"; |
| 16 | 16 | import { agent as defaults } from "../env.ts"; |
| 17 | 17 | 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"; | |
| 18 | 19 | import type { Who } from "../who.ts"; |
| 19 | 20 | import { owner } from "../week.ts"; |
| 20 | 21 | |
| @@ -186,6 +187,35 @@ export const draftLink = (draft: Draft, on: string): string => | ||
| 186 | 187 | ? `<li class="draft"><a class="draft-open" href="/deals/${draft.deal_id}#${draft.moment}">${draftLine(draft, on, true)}</a></li>` |
| 187 | 188 | : `<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>`; |
| 188 | 189 | |
| 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 | ||
| 189 | 219 | export function sheet(deal: DealView, d: DeadlineView, who: Who, on: string, to: string, agent: Agent = defaults, typed?: { id: number; note: string }): string { |
| 190 | 220 | const id = sheetId(d); |
| 191 | 221 | const may = canChange(who, deal); |
| @@ -219,9 +249,20 @@ export function sheet(deal: DealView, d: DeadlineView, who: Who, on: string, to: | ||
| 219 | 249 | ? `<form method="post" action="/deadlines/${d.id}/waive">${hidden({ waived: 0, to })} |
| 220 | 250 | <button class="button">Un-waive</button> |
| 221 | 251 | </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 | }) | |
| 222 | 262 | : `<form method="post" action="/deadlines/${d.id}/done">${hidden({ done: d.done_at === null ? 1 : 0, to })} |
| 223 | 263 | <button class="button${d.done_at === null ? "" : " button-quiet"}">${d.done_at === null ? "Mark done" : "Reopen"}</button> |
| 224 | </form>${ | |
| 264 | </form>` | |
| 265 | }${ | |
| 225 | 266 | isOpen(d) && isWaivable(d.kind) |
| 226 | 267 | ? confirmRemove({ |
| 227 | 268 | summary: "Waive…", |
| @@ -284,6 +325,7 @@ export function sheet(deal: DealView, d: DeadlineView, who: Who, on: string, to: | ||
| 284 | 325 | ${conflictLines(deal, d)} |
| 285 | 326 | ${nonBusinessLine(d)}${may ? rollForm(d, to) : ""} |
| 286 | 327 | ${moves} |
| 328 | ${papersSection(d, may, to)} | |
| 287 | 329 | <section><h3>Reminders</h3>${isOpen(d) ? `<p class="muted">${remindsLine(d.lead_days)}</p>` : ""}${reminders}</section> |
| 288 | 330 | ${actions} |
| 289 | 331 | </div>`; |
src/pages/timeline.ts +9 −1
| @@ -25,6 +25,14 @@ function todo(lines: Guidance[]): string { | ||
| 25 | 25 | .join(""); |
| 26 | 26 | } |
| 27 | 27 | |
| 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 | ||
| 28 | 36 | export function timelinePage(input: { who: Who; agent?: Agent | undefined; deal: DealView; on: string }): string { |
| 29 | 37 | const { who, deal, on } = input; |
| 30 | 38 | const agent = input.agent ?? defaults; |
| @@ -35,7 +43,7 @@ export function timelinePage(input: { who: Who; agent?: Agent | undefined; deal: | ||
| 35 | 43 | <p class="timeline-date">${escape(r.spoken)}${r.where === "" ? "" : ` · ${escape(r.where)}`}</p> |
| 36 | 44 | <p class="timeline-label"><b>${escape(r.label)}</b></p> |
| 37 | 45 | <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)} | |
| 39 | 47 | </li>`, |
| 40 | 48 | ) |
| 41 | 49 | .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("<b>Access</b> 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 <i>access</i> 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")); | |
| 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"; | ||
| 44 | 44 | import { tally } from "./book.ts"; |
| 45 | 45 | import { AMOUNT_MAX, RATE_MAX } from "./money.ts"; |
| 46 | 46 | import { progress } from "./progress.ts"; |
| 47 | import { addPaper, openPapersOf, removePaper, stillOpenLine, tickPaper } from "./papers.ts"; | |
| 47 | 48 | import { |
| 48 | 49 | addDeadline, |
| 49 | 50 | addParty, |
| @@ -336,6 +337,11 @@ export const DEAL: Schema = { | ||
| 336 | 337 | 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." }, |
| 337 | 338 | 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." }, |
| 338 | 339 | 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 | }, | |
| 339 | 345 | 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." }, |
| 340 | 346 | place: { type: ["string", "null"], maxLength: 200, description: "An inspection's or walkthrough's place; null while unset and for any other kind." }, |
| 341 | 347 | 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[] = [ | ||
| 1266 | 1272 | const f = await form(c); |
| 1267 | 1273 | if (f instanceof Response) return f; |
| 1268 | 1274 | 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); | |
| 1269 | 1279 | return done(markDone(c.db, c.who, { id: Number(c.params.id), done: isDone }), backTo(f.to), isDone ? "Marked done." : "Reopened."); |
| 1270 | 1280 | }, |
| 1271 | 1281 | }, |
| @@ -1326,6 +1336,43 @@ export const routes: readonly Route[] = [ | ||
| 1326 | 1336 | return done(set, backTo(f.to)); |
| 1327 | 1337 | }, |
| 1328 | 1338 | }, |
| 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 | }, | |
| 1329 | 1376 | { |
| 1330 | 1377 | method: "POST", |
| 1331 | 1378 | path: "/deadlines/:id/appointment", |
src/sample.ts +16
| @@ -14,6 +14,7 @@ import { zoneOf } from "./profile.ts"; | ||
| 14 | 14 | import { type PartyRole } from "./deadlines.ts"; |
| 15 | 15 | import { BRIEF_LENGTHS, validEmail } from "./env.ts"; |
| 16 | 16 | import { APPOINTMENT_KINDS } from "./appointment.ts"; |
| 17 | import { insertPaper } from "./papers.ts"; | |
| 17 | 18 | import { insertDeal, type Reach } from "./deals.ts"; |
| 18 | 19 | import type { Side } from "./side.ts"; |
| 19 | 20 | |
| @@ -49,6 +50,8 @@ interface Sample { | ||
| 49 | 50 | readonly moved?: readonly [string, number, string]; |
| 50 | 51 | /** The agent's working note on a date: date kind, note. */ |
| 51 | 52 | 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])[]; | |
| 52 | 55 | /** A reminder already sent, in the agent's own edited words: date kind, party role, moment, subject, body. */ |
| 53 | 56 | readonly sent?: readonly [string, PartyRole, "before" | "day_of", string, string]; |
| 54 | 57 | } |
| @@ -72,6 +75,11 @@ const SAMPLES: readonly Sample[] = [ | ||
| 72 | 75 | ], |
| 73 | 76 | reach: [["Maya & Tom Okafor", "text"], ["Priya Nair, Coastline Mortgage", "email"], ["Jordan Ellis, Crestline Homes", "call"]], |
| 74 | 77 | 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 | ], | |
| 75 | 83 | sent: [ |
| 76 | 84 | "inspection", |
| 77 | 85 | "buyer", |
| @@ -97,6 +105,10 @@ const SAMPLES: readonly Sample[] = [ | ||
| 97 | 105 | reach: [["Samuel Reyes", "call"], ["Owen Brandt, First Keel Bank", "email"]], |
| 98 | 106 | done: ["earnest"], |
| 99 | 107 | 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 | ], | |
| 100 | 112 | custom: [["HOA documents review ends", 9, "buyer", "Read the HOA budget, rules and minutes; object in writing if needed"]], |
| 101 | 113 | }, |
| 102 | 114 | { |
| @@ -444,6 +456,10 @@ function writeSamples(db: Database, on: string): void { | ||
| 444 | 456 | 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); |
| 445 | 457 | for (const [kind, note] of s.notes ?? []) |
| 446 | 458 | 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 | } | |
| 447 | 463 | for (const [label, days, owes, what] of s.custom ?? []) |
| 448 | 464 | 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); |
| 449 | 465 | 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, | ||
| 1098 | 1098 | press(back, "e", "Tex Lender\r", "[email protected]\r", "\r", "\r"); |
| 1099 | 1099 | expect(read().parties.find((p) => p.id === tex.id)!.reach).toBe(""); |
| 1100 | 1100 | }, 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 @@ | ||
| 15 | 15 | * deal one deal's dates and parties; a adds a date, p a party, A corrects the address, C sets the closing time and place, M amends the contract, S drafts a status update for the party under the cursor, D removes it, F marks it fell through (or brings it back), Y changes the side (buyer, listing or both) |
| 16 | 16 | * |
| 17 | 17 | * On a date (dates or deal view): x marks it done, W waives it (the buyer gave it up), u reopens it or un-waives it, m moves it |
| 18 | * (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 | |
| 19 | 19 | * deal (address, buyer, seller, contract and closing dates, side); d, r and b move |
| 20 | 20 | * between views; esc cancels a prompt; q leaves. / finds a deal by address or |
| 21 | 21 | * 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"; | ||
| 92 | 92 | import { listAudit } from "./actions.ts"; |
| 93 | 93 | import { clashText, clashesText } from "./overlap.ts"; |
| 94 | 94 | import { owner, thisWeek } from "./week.ts"; |
| 95 | import { PAPERS_MAX, addPaper, openPapersOf, papersLine, removePaper, stillOpenLine, suggestedPaper, tickPaper } from "./papers.ts"; | |
| 95 | 96 | import { SIDE_CHOICE, SIDE_LABEL, SIDE_REFUSAL, sideTyped } from "./side.ts"; |
| 96 | 97 | |
| 97 | 98 | const esc = "\x1b["; |
| @@ -213,15 +214,52 @@ export function terminal( | ||
| 213 | 214 | prompt = { steps, answers: [], text: "", ...(max === undefined ? {} : { max }), finish }; |
| 214 | 215 | }; |
| 215 | 216 | |
| 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 | ||
| 216 | 247 | function onDate(key: string): boolean { |
| 217 | 248 | 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; | |
| 219 | 250 | if (row === undefined) { |
| 220 | 251 | said = "No date here."; |
| 221 | 252 | return true; |
| 222 | 253 | } |
| 223 | 254 | 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); | |
| 225 | 263 | if (key === "u") |
| 226 | 264 | said = |
| 227 | 265 | date.waived_at === null |
| @@ -776,8 +814,9 @@ export function terminal( | ||
| 776 | 814 | const lead = isOpen(date) ? dim(` reminds ${date.lead_days}d before`) : ""; |
| 777 | 815 | const note = date.note === "" ? "" : dim(` note: ${date.note}`); |
| 778 | 816 | const where = appointmentLine(date) === "" ? "" : dim(` ${appointmentLine(date)}`); |
| 817 | const paper = date.papers.length === 0 ? "" : dim(` ${papersLine(date.papers)}`); | |
| 779 | 818 | 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}`; | |
| 781 | 820 | }; |
| 782 | 821 | |
| 783 | 822 | function screen(): string[] { |
src/timeline.ts +3 −1
| @@ -41,12 +41,14 @@ export interface TimelineRow { | ||
| 41 | 41 | readonly urgency: Urgency; |
| 42 | 42 | /** What the client does for it: lines for an open date only, each labelled on a deal for both sides. */ |
| 43 | 43 | 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 }[]; | |
| 44 | 46 | } |
| 45 | 47 | |
| 46 | 48 | export function timelineRows(deal: DealView, on: string): TimelineRow[] { |
| 47 | 49 | return deal.deadlines.filter((d) => !isInternal(d)).map((d) => { |
| 48 | 50 | const u = urgencyOf(d, on); |
| 49 | 51 | 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 })) }; | |
| 51 | 53 | }); |
| 52 | 54 | } |
static/app.css +27
| @@ -570,6 +570,26 @@ footer code { | ||
| 570 | 570 | font-size: var(); |
| 571 | 571 | } |
| 572 | 572 | |
| 573 | /* Paperwork checklist on a date. */ | |
| 574 | .papers-list { | |
| 575 | list-style: none; | |
| 576 | margin: 0 0 var(); | |
| 577 | padding: 0; | |
| 578 | } | |
| 579 | .paper-row { | |
| 580 | display: flex; | |
| 581 | flex-wrap: wrap; | |
| 582 | align-items: center; | |
| 583 | gap: var(); | |
| 584 | padding: var() 0; | |
| 585 | } | |
| 586 | .paper-row > span { | |
| 587 | flex: 1 1 12rem; | |
| 588 | } | |
| 589 | .paper-row form { | |
| 590 | margin: 0; | |
| 591 | } | |
| 592 | ||
| 573 | 593 | /* Reminder drafts. */ |
| 574 | 594 | .drafts { |
| 575 | 595 | list-style: none; |
| @@ -872,6 +892,13 @@ a.week-row { | ||
| 872 | 892 | .timeline-status { |
| 873 | 893 | font-weight: 650; |
| 874 | 894 | } |
| 895 | .timeline-papers ul { | |
| 896 | margin: 0; | |
| 897 | padding-left: var(); | |
| 898 | } | |
| 899 | .timeline-row .timeline-papers { | |
| 900 | grid-column: 1 / -1; | |
| 901 | } | |
| 875 | 902 | .timeline-row .timeline-todo { |
| 876 | 903 | grid-column: 1 / -1; |
| 877 | 904 | } |
new above
offline
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.