Public: anyone can read this; the crew can change it.
The morning tick lists the reminders due today as one digest the agent can read at a glance #319
Check · Review · Land · Live — Its check is red.
mason/deal-clock:mason/315-the-morning-tick-lists-the-reminders-due → main 3 commits fork 1 thread · 1 unresolved
8 files
changed +151
−7
README.md +9
| @@ -87,6 +87,15 @@ with a link to the page it names. In the terminal press `G` for the same steps. | ||
| 87 | 87 | timeline and at the foot of every new reminder. (Only an admin can save |
| 88 | 88 | it; **Use defaults** goes back to the sample agent.) |
| 89 | 89 | |
| 90 | ## The morning digest | |
| 91 | ||
| 92 | Under the walk, the review page has a "Due today" list: the waiting reminders | |
| 93 | grouped by deal, with the party and the draft to read. Past 50 reminders it | |
| 94 | shows the first 50 and says how many more the walk reaches. When nothing is | |
| 95 | due it says "Nothing is due today." In the terminal, **Z** opens the same | |
| 96 | list. Nothing is sent from the list; each message still needs your tap on | |
| 97 | Send (or **s** in the terminal's reminders). | |
| 98 | ||
| 90 | 99 | ## Holding a reminder for later |
| 91 | 100 | |
| 92 | 101 | On the review page, **Tomorrow** holds a draft until the next morning. To hold |
docs/TECHNICAL.md +1 −1
| @@ -214,7 +214,7 @@ its deal — where `a` adds a date, | ||
| 214 | 214 | `p` adds a party, `e` edits the party under the cursor (the cursor walks the |
| 215 | 215 | dates, then the parties), `X` removes the date or party under it after you |
| 216 | 216 | type `yes`, and `D` removes the deal likewise — and `h` |
| 217 | shows done dates. `w` lists the open dates overdue or due in the next seven days, by day (`src/week.ts`, as the This week card). `r` lists today's reminders: enter reads a draft, `e` | |
| 217 | shows done dates. `w` lists the open dates overdue or due in the next seven days, by day (`src/week.ts`, as the This week card). `Z` lists today's due reminders by deal with their drafts (the Review page's digest, first 50), read-only. `r` lists today's reminders: enter reads a draft, `e` | |
| 218 | 218 | types new words for it (a subject, then a message, where `\n` is a line |
| 219 | 219 | break and an empty answer keeps the draft's), and `s` sends what you read, |
| 220 | 220 | under the same limits as the page. `j`/`k` move, `b` goes back, `q` quits. What you change there |
src/digest.test.ts added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | /** The morning digest groups today's unsent reminders by deal, on the Review page. */ | |
| 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 { DIGEST_EMPTY, DIGEST_MAX, digestByDeal, type Draft } from "./reminders.ts"; | |
| 7 | import { serve } from "./server.ts"; | |
| 8 | import { sign } from "./who.ts"; | |
| 9 | ||
| 10 | const draft = (deal_id: number, address: string, party_name: string) => ({ deal_id, address, party_name }) as Draft; | |
| 11 | ||
| 12 | test("drafts are grouped by deal, in first-seen order", () => { | |
| 13 | const g = digestByDeal([draft(2, "B", "x"), draft(1, "A", "y"), draft(2, "B", "z")]); | |
| 14 | expect(g.map((x) => [x.deal_id, x.address, x.drafts.map((d) => d.party_name)])).toEqual([ | |
| 15 | [2, "B", ["x", "z"]], | |
| 16 | [1, "A", ["y"]], | |
| 17 | ]); | |
| 18 | expect(digestByDeal([])).toEqual([]); | |
| 19 | }); | |
| 20 | ||
| 21 | const KEY = "a-key-only-the-forge-holds"; | |
| 22 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-digest-")), identityKey: KEY }); | |
| 23 | afterAll(() => server.stop(true)); | |
| 24 | const id = `u1|alice|user|${Date.now() + 600_000}`; | |
| 25 | const headers = { "x-forge-identity": id, "x-forge-signature": sign(id, KEY) }; | |
| 26 | ||
| 27 | test("the Review page lists due drafts under each deal, with no automatic send", async () => { | |
| 28 | const page = await (await fetch(`http://${server.hostname}:${server.port}/reminders/today`, { headers })).text(); | |
| 29 | const digest = page.slice(page.indexOf('id="digest"')); | |
| 30 | expect(digest).toContain("<h2>Due today</h2>"); | |
| 31 | expect(digest).toMatch(/<li><a href="\/deals\/\d+">[^<]+<\/a><ul><li><b>/); | |
| 32 | expect(digest).toContain("<details><summary>"); | |
| 33 | expect(digest).toContain("Nothing is sent until you tap Send"); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a long queue is cut to the digest's limit and says how many more the walk reaches", async () => { | |
| 37 | const { reviewPage } = await import("./pages/review.ts"); | |
| 38 | const many = Array.from({ length: DIGEST_MAX + 7 }, (_, i) => ({ ...draft(i + 1, `${i} Row`, "p"), label: "L", subject: "s", body: "b", party_role: "buyer", due: "2026-11-12", moment: "before", lead_days: 3, held: null, contact: "", email: "", phone: "", reach: "" }) as Draft); | |
| 39 | const page = reviewPage({ who: { role: "user", id: "u", name: "a" }, queue: many, q: "", at: 0, done: 0 }); | |
| 40 | expect(page.slice(page.indexOf('id="digest"')).match(/<details>/g)).toHaveLength(DIGEST_MAX); | |
| 41 | expect(page).toContain("And 7 more"); | |
| 42 | }); | |
| 43 | ||
| 44 | test("with nothing due the digest says so", async () => { | |
| 45 | const { reviewPage } = await import("./pages/review.ts"); | |
| 46 | expect(reviewPage({ who: { role: "user", id: "u", name: "a" }, queue: [], q: "", at: 0, done: 0 })).toContain(DIGEST_EMPTY); | |
| 47 | expect(DIGEST_EMPTY).toBe("Nothing is due today."); | |
| 48 | }); |
src/pages/review.ts +23 −2
| @@ -13,7 +13,7 @@ import { addDays, dayOf, spoken } from "../dates.ts"; | ||
| 13 | 13 | import { PARTY_LABEL } from "../deadlines.ts"; |
| 14 | 14 | import { asset, escape } from "../http.ts"; |
| 15 | 15 | import type { ClosingFilter } from "../closing-window.ts"; |
| 16 | import { heldFrom, momentLabel, type Draft } from "../reminders.ts"; | |
| 16 | import { DIGEST_EMPTY, DIGEST_MAX, digestByDeal, heldFrom, momentLabel, type Draft } from "../reminders.ts"; | |
| 17 | 17 | import type { Who } from "../who.ts"; |
| 18 | 18 | import { layout } from "./layout.ts"; |
| 19 | 19 | import { hidden } from "./sheet.ts"; |
| @@ -86,7 +86,28 @@ export function reviewPage(input: { | ||
| 86 | 86 | draft === undefined |
| 87 | 87 | ? `<section class="card narrow"><h1>Review today's reminders</h1><p>${input.settled === undefined || settledLine(input.settled) === "" ? "All of today's reminders are sent or declined." : `All of today's reminders are sent or declined. ${escape(settledLine(input.settled))}`}</p>${nextLine(input.next)}<p>${back}</p></section>` |
| 88 | 88 | : form(draft, { q, closing, at, done, position: done + at + 1, total: done + queue.length, error, typed, on: dayOf(agent ?? {}) }, back); |
| 89 | return layout({ body, who, agent, waiting: input.waiting, tour: input.tour, path: REVIEW_PATH, heading: "Review today's reminders", notice: input.notice, open: input.open, live: false }); | |
| 89 | return layout({ body: body + digest(queue), who, agent, waiting: input.waiting, tour: input.tour, path: REVIEW_PATH, heading: "Review today's reminders", notice: input.notice, open: input.open, live: false }); | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * The morning digest under the walk: every reminder still due today, grouped | |
| 94 | * by deal, each with its party and draft to read. It is the same queue the | |
| 95 | * walk uses; nothing is sent from here, a person taps Send above. | |
| 96 | */ | |
| 97 | function digest(queue: readonly Draft[]): string { | |
| 98 | if (queue.length === 0) return `<section class="card narrow" id="digest"><h2>Due today</h2><p>${DIGEST_EMPTY}</p></section>`; | |
| 99 | const shown = queue.slice(0, DIGEST_MAX); | |
| 100 | const more = queue.length - shown.length; | |
| 101 | const groups = digestByDeal(shown) | |
| 102 | .map( | |
| 103 | (g) => `<li><a href="/deals/${g.deal_id}">${escape(g.address)}</a><ul>${g.drafts | |
| 104 | .map( | |
| 105 | (d) => `<li><b>${escape(d.label)}</b> <span class="muted">to ${escape(d.party_name)} · ${escape(PARTY_LABEL[d.party_role])} · ${escape(spoken(d.due))}</span><details><summary>${escape(d.subject)}</summary><p class="file-words">${escape(d.body)}</p></details></li>`, | |
| 106 | ) | |
| 107 | .join("")}</ul></li>`, | |
| 108 | ) | |
| 109 | .join(""); | |
| 110 | return `<section class="card narrow" id="digest"><h2>Due today</h2><p class="muted small">${queue.length} waiting, by deal. Nothing is sent until you tap Send on each.</p><ul>${groups}</ul>${more === 0 ? "" : `<p class="muted small">And ${more} more: the walk above reaches every one.</p>`}</section>`; | |
| 90 | 111 | } |
| 91 | 112 | |
| 92 | 113 | /** "Next: Thu Nov 12, Financing reminder to Priya Nair, 9 Orchard Row", linked to the deal; nothing when nothing is coming. */ |
src/reminders.ts +24
| @@ -536,3 +536,27 @@ export const waitingCount = (deals: readonly DealView[], day: string, agent: Age | ||
| 536 | 536 | (n, deal) => n + waitingIn(`${day}|${JSON.stringify(agent)}|${print(deal)}`, () => todaysDrafts([deal], day, agent).filter(unsent).length), |
| 537 | 537 | 0, |
| 538 | 538 | ); |
| 539 | ||
| 540 | /** The most reminders the digest spells out, on the page and in the terminal: a book-sized list would slow the morning's first paint. */ | |
| 541 | export const DIGEST_MAX = 50; | |
| 542 | ||
| 543 | /** What the digest says when no reminder is due. */ | |
| 544 | export const DIGEST_EMPTY = "Nothing is due today."; | |
| 545 | ||
| 546 | /** One deal's share of the morning digest: its unsent drafts, in the order given. */ | |
| 547 | export interface DigestGroup { | |
| 548 | readonly deal_id: number; | |
| 549 | readonly address: string; | |
| 550 | readonly drafts: readonly Draft[]; | |
| 551 | } | |
| 552 | ||
| 553 | /** Today's drafts grouped by deal, deals in the order they first appear; the grouping the Review digest shows. */ | |
| 554 | export function digestByDeal(drafts: readonly Draft[]): DigestGroup[] { | |
| 555 | const groups = new Map<number, Draft[]>(); | |
| 556 | for (const d of drafts) { | |
| 557 | const list = groups.get(d.deal_id); | |
| 558 | if (list === undefined) groups.set(d.deal_id, [d]); | |
| 559 | else list.push(d); | |
| 560 | } | |
| 561 | return [...groups].map(([deal_id, list]) => ({ deal_id, address: list[0]!.address, drafts: list })); | |
| 562 | } |
src/review.test.ts +2 −1
| @@ -170,7 +170,8 @@ const bareDraft = async (address: string) => { | ||
| 170 | 170 | expect(made.status).toBe(201); |
| 171 | 171 | for (let at = 0; at < 40; at++) { |
| 172 | 172 | const page = await get(`/reminders/today?at=${at}`); |
| 173 | if (page.includes(address)) return { page, at }; | |
| 173 | const walk = page.slice(0, page.indexOf(`id="digest"`)); // the digest below lists every address; only the walked draft counts | |
| 174 | if (walk.includes(address)) return { page, at }; | |
| 174 | 175 | } |
| 175 | 176 | throw new Error("draft not found"); |
| 176 | 177 | }; |
src/terminal.test.ts +18
| @@ -1268,3 +1268,21 @@ test("K keeps a private note on the open deal, shows it in the deal header, clea | ||
| 1268 | 1268 | expect(lines(guest).join("\n")).not.toContain("Private note"); |
| 1269 | 1269 | expect(deal().note).toBe(""); |
| 1270 | 1270 | }); |
| 1271 | ||
| 1272 | test("Z lists today's due reminders by deal with their drafts, and sends nothing", () => { | |
| 1273 | const t = terminal(db, alice, context); | |
| 1274 | const before = deal().sent.length; | |
| 1275 | t.press("Z"); | |
| 1276 | const text = lines(t).join("\n"); | |
| 1277 | expect(text).toContain("Due today"); | |
| 1278 | expect(text).toContain("12 Elm St"); | |
| 1279 | expect(text).toMatch(/to Ann Buyer \(buyer\)/); | |
| 1280 | expect(text).not.toContain("Nothing is due today."); | |
| 1281 | expect(deal().sent.length).toBe(before); | |
| 1282 | }); | |
| 1283 | ||
| 1284 | test("Z says nothing is due today to a guest, who is shown no deals", () => { | |
| 1285 | const t = terminal(db, { role: "anon", id: "", name: "" }, context); | |
| 1286 | t.press("Z"); | |
| 1287 | expect(lines(t).join("\n")).toContain("Nothing is due today."); | |
| 1288 | }); |
src/terminal.ts +26 −3
| @@ -87,7 +87,7 @@ import { IMPORT_BYTES, importText } from "./import.ts"; | ||
| 87 | 87 | import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; |
| 88 | 88 | import { join } from "node:path"; |
| 89 | 89 | import { coming, comingLine } from "./coming.ts"; |
| 90 | import { drafts, heldFrom, inClockOrder, momentLabel, pending, remindsLine, sentLabel, unsent, type Agent, type Draft } from "./reminders.ts"; | |
| 90 | import { DIGEST_EMPTY, DIGEST_MAX, digestByDeal, drafts, heldFrom, inClockOrder, momentLabel, pending, remindsLine, sentLabel, unsent, type Agent, type Draft } from "./reminders.ts"; | |
| 91 | 91 | import type { AuditRow } from "./audit.ts"; |
| 92 | 92 | import type { Result } from "./result.ts"; |
| 93 | 93 | import type { Who } from "./who.ts"; |
| @@ -110,7 +110,7 @@ const ACTIVITY_ROWS = 20; | ||
| 110 | 110 | const COLOUR: Readonly<Record<Urgency, string>> = { overdue: "31", soon: "33", later: "36", done: "2", waived: "2" }; |
| 111 | 111 | const tint = (u: Urgency, s: string): string => `${esc}${COLOUR[u]}m${s}${esc}0m`; |
| 112 | 112 | |
| 113 | type View = "dates" | "week" | "people" | "tour" | "reminders" | "deal" | "messages" | "timeline" | "file" | "profile" | "activity"; | |
| 113 | type View = "dates" | "week" | "people" | "tour" | "reminders" | "digest" | "deal" | "messages" | "timeline" | "file" | "profile" | "activity"; | |
| 114 | 114 | interface Row { |
| 115 | 115 | readonly deal: DealView; |
| 116 | 116 | readonly date: DeadlineView; |
| @@ -723,6 +723,11 @@ export function terminal( | ||
| 723 | 723 | cursor = 0; |
| 724 | 724 | onlyMessages = false; |
| 725 | 725 | trail = []; |
| 726 | } else if (key === "Z") { | |
| 727 | view = "digest"; | |
| 728 | showHeld = false; | |
| 729 | cursor = 0; | |
| 730 | reading = undefined; | |
| 726 | 731 | } else if (key === "r") { |
| 727 | 732 | view = "reminders"; |
| 728 | 733 | showHeld = false; |
| @@ -929,6 +934,22 @@ export function terminal( | ||
| 929 | 934 | lines.push(` ${tint(o.urgency, `${spoken(o.due).padEnd(12)} ${relative(o.due, day).padEnd(12)}`)} ${o.label} — ${o.address}`); |
| 930 | 935 | } |
| 931 | 936 | } |
| 937 | } else if (view === "digest") { | |
| 938 | const due = reminderRows() | |
| 939 | .map((r) => r.draft) | |
| 940 | .filter(unsent); | |
| 941 | lines.push(bold("Due today"), ""); | |
| 942 | if (due.length === 0) lines.push(dim(DIGEST_EMPTY)); | |
| 943 | else { | |
| 944 | lines.push(dim(`${due.length} waiting, by deal. Nothing is sent from here; r reminders sends.`), ""); | |
| 945 | for (const g of digestByDeal(due.slice(0, DIGEST_MAX))) { | |
| 946 | lines.push(bold(g.address)); | |
| 947 | for (const d of g.drafts.map(edited)) { | |
| 948 | lines.push(` ${d.label} — to ${d.party_name} (${PARTY_LABEL[d.party_role].toLowerCase()}) · ${spoken(d.due)}`, ` ${bold(d.subject)}`, ...d.body.split("\n").map((l) => ` ${l}`), ""); | |
| 949 | } | |
| 950 | } | |
| 951 | if (due.length > DIGEST_MAX) lines.push(dim(`And ${due.length - DIGEST_MAX} more: r reminders reaches every one.`)); | |
| 952 | } | |
| 932 | 953 | } else if (view === "tour") { |
| 933 | 954 | const listed = listDeals(db, me); |
| 934 | 955 | if (!listed.ok) lines.push(listed.message); |
| @@ -1068,6 +1089,8 @@ export function terminal( | ||
| 1068 | 1089 | : "d dates · q quit (only an admin changes the profile)" |
| 1069 | 1090 | : view === "tour" |
| 1070 | 1091 | ? "G tour · d dates · w this week · r reminders · P people · q quit" |
| 1092 | : view === "digest" | |
| 1093 | ? "Z digest · r reminders · d dates · w this week · P people · q quit" | |
| 1071 | 1094 | : view === "people" |
| 1072 | 1095 | ? "P people · d dates · w this week · r reminders · q quit" |
| 1073 | 1096 | : view === "reminders" |
| @@ -1080,7 +1103,7 @@ export function terminal( | ||
| 1080 | 1103 | ? "b back to the deal · d dates · q quit" |
| 1081 | 1104 | : view === "deal" |
| 1082 | 1105 | ? "j/k move · t client timeline · f broker file · s messages · x done · W waive · u reopen/un-waive · m move · U undo move · N next business day · l remind lead · T note · @ time and place (inspection, walkthrough) · a add date · p add party · e edit party · A edit address · K private note · C closing time and place · M amend the contract · V closing moved · S status update to the party · X remove date/party · D remove deal · b back · q quit" |
| 1083 | : `j/k move · x done · W waive · u reopen · m move · U undo move · N next business day · l lead · T note · @ time and place · o open deal · n new deal · c check dates · / find · R restore samples · O bring deals back · h ${showDone ? "hide" : "show"} done · w this week · r reminders · P people · I profile · E export · L activity · q quit`; | |
| 1106 | : `j/k move · x done · W waive · u reopen · m move · U undo move · N next business day · l lead · T note · @ time and place · o open deal · n new deal · c check dates · / find · R restore samples · O bring deals back · h ${showDone ? "hide" : "show"} done · w this week · r reminders · Z digest · P people · I profile · E export · L activity · q quit`; | |
| 1084 | 1107 | lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys)); |
| 1085 | 1108 | } |
| 1086 | 1109 | return lines; |
Docblock for digest() sits above DIGEST_MAX; move it down onto function digest.