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 #322

Merged opened this

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

Merged

mason/deal-clock:mason/315-the-morning-tick-lists-the-reminders-due → main 6 commits fork 1 thread · 1 unresolved

  • Merged. mason/315-the-morning-tick-lists-the-reminders-due was merged into main as c5121f3.

Review

Sign in to review this change.
10 files changed +203 −10
README.md +9
@@ -92,6 +92,15 @@ with a link to the page it names. In the terminal press `G` for the same steps.
9292 timeline and at the foot of every new reminder. (Only an admin can save
9393 it; **Use defaults** goes back to the sample agent.)
9494
95 ## The morning digest
96
97 Under the walk, the review page has a "Due today" list: the waiting reminders
98 grouped by deal, with the party and the draft to read. Past 50 reminders it
99 shows the first 50 and says how many more the walk reaches. When nothing is
100 due it says "Nothing is due today." In the terminal, **Z** opens the same
101 list. Nothing is sent from the list; each message still needs your tap on
102 Send (or **s** in the terminal's reminders).
103
95104 ## Holding a reminder for later
96105
97106 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,
214214 `p` adds a party, `e` edits the party under the cursor (the cursor walks the
215215 dates, then the parties), `X` removes the date or party under it after you
216216 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`
218218 types new words for it (a subject, then a message, where `\n` is a line
219219 break and an empty answer keeps the draft's), and `s` sends what you read,
220220 under the same limits as the page. `j`/`k` move, `b` goes back, `q` quits. What you change there
src/digest.test.ts added +77
@@ -0,0 +1,77 @@
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 { openDb } from "./db.ts";
7 import { clearSamples } from "./sample.ts";
8 import { reviewPage } from "./pages/review.ts";
9 import { DIGEST_EMPTY, DIGEST_MAX, digestByDeal, type Draft } from "./reminders.ts";
10 import { serve } from "./server.ts";
11 import { sign } from "./who.ts";
12
13 const draft = (deal_id: number, address: string, party_name: string) => ({ deal_id, address, party_name }) as Draft;
14
15 test("drafts are grouped by deal, in first-seen order", () => {
16 const g = digestByDeal([draft(2, "B", "x"), draft(1, "A", "y"), draft(2, "B", "z")]);
17 expect(g.map((x) => [x.deal_id, x.address, x.drafts.map((d) => d.party_name)])).toEqual([
18 [2, "B", ["x", "z"]],
19 [1, "A", ["y"]],
20 ]);
21 expect(digestByDeal([])).toEqual([]);
22 });
23
24 const KEY = "a-key-only-the-forge-holds";
25 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-digest-")), identityKey: KEY });
26 afterAll(() => server.stop(true));
27 const id = `u1|alice|user|${Date.now() + 600_000}`;
28 const headers = { "x-forge-identity": id, "x-forge-signature": sign(id, KEY) };
29
30 test("the Review page lists due drafts under each deal, with no automatic send", async () => {
31 const page = await (await fetch(`http://${server.hostname}:${server.port}/reminders/today`, { headers })).text();
32 const digest = page.slice(page.indexOf('id="digest"'));
33 expect(digest).toContain("<h2>Due today</h2>");
34 expect(digest).toMatch(/<li><a href="\/deals\/\d+">[^<]+<\/a><ul><li><b>/);
35 expect(digest).toContain("<details><summary>");
36 expect(digest).toContain("Nothing is sent until you tap Send");
37 });
38
39 test("a long queue is cut to the digest's limit and says how many more the walk reaches", async () => {
40 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);
41 const page = reviewPage({ who: { role: "user", id: "u", name: "a" }, queue: many, q: "", at: 0, done: 0 });
42 expect(page.slice(page.indexOf('id="digest"')).match(/<details>/g)).toHaveLength(DIGEST_MAX);
43 expect(page).toContain("And 7 more");
44 });
45
46 test("with nothing due the digest says so", async () => {
47 expect(reviewPage({ who: { role: "user", id: "u", name: "a" }, queue: [], q: "", at: 0, done: 0 })).toContain(DIGEST_EMPTY);
48 expect(DIGEST_EMPTY).toBe("Nothing is due today.");
49 });
50
51 const get = async (path: string, h: Record<string, string> = headers) => (await fetch(`http://${server.hostname}:${server.port}${path}`, { headers: h })).text();
52 const digestOf = (page: string) => page.slice(page.indexOf('id="digest"'));
53
54 test("a Find or closing filter that hides every draft from the walk does not empty the digest", async () => {
55 const filtered = await get("/reminders/today?q=zzz-matches-no-deal");
56 expect(filtered).toContain("All of today's reminders are sent or declined.");
57 const digest = digestOf(filtered);
58 expect(digest).not.toContain(DIGEST_EMPTY);
59 expect(digest).toMatch(/<li><a href="\/deals\/\d+">/);
60 const waiting = (page: string) => digestOf(page).match(/(\d+) waiting/)![1];
61 expect(waiting(filtered)).toBe(waiting(await get("/reminders/today")));
62 });
63
64 test("through the real route, with no deals to draw from, the digest says nothing is due", async () => {
65 const dir = mkdtempSync(join(tmpdir(), "app-digest-empty-"));
66 const db = openDb(dir);
67 clearSamples(db);
68 db.close();
69 const empty = serve({ port: 0, dataDir: dir, identityKey: KEY });
70 try {
71 const page = await (await fetch(`http://${empty.hostname}:${empty.port}/reminders/today`, { headers })).text();
72 expect(digestOf(page)).toContain(DIGEST_EMPTY);
73 expect(page).toContain("All of today's reminders are sent or declined.");
74 } finally {
75 empty.stop(true);
76 }
77 });
src/pages/review.ts +25 −2
@@ -13,7 +13,7 @@ import { addDays, dayOf, spoken } from "../dates.ts";
1313 import { PARTY_LABEL } from "../deadlines.ts";
1414 import { asset, escape } from "../http.ts";
1515 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";
1717 import type { Who } from "../who.ts";
1818 import { layout } from "./layout.ts";
1919 import { hidden } from "./sheet.ts";
@@ -61,6 +61,8 @@ export function reviewPage(input: {
6161 agent?: Agent | undefined;
6262 /** The drafts still to send, in the clock's order. */
6363 queue: readonly Draft[];
64 /** Every draft due today, whatever the Find or closing filter hides from the walk; the digest lists these. Defaults to the queue. */
65 due?: readonly Draft[] | undefined;
6466 q: string;
6567 closing?: ClosingFilter;
6668 /** How many of the queue this round has skipped past. */
@@ -86,7 +88,28 @@ export function reviewPage(input: {
8688 draft === undefined
8789 ? `<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>`
8890 : 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 });
91 return layout({ body: body + digest(input.due ?? 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 });
92 }
93
94 /**
95 * The morning digest under the walk: every reminder still due today, grouped
96 * by deal, each with its party and draft to read. It is the same queue the
97 * walk draws from, before the Find and closing filters narrow it; nothing is sent from here, a person taps Send above.
98 */
outdated

queue is filtered by q/closing, so this claims nothing is due when a filter hides due reminders. Build the digest from the unfiltered due list.

99 function digest(queue: readonly Draft[]): string {
100 if (queue.length === 0) return `<section class="card narrow" id="digest"><h2>Due today</h2><p>${DIGEST_EMPTY}</p></section>`;
101 const shown = queue.slice(0, DIGEST_MAX);
102 const more = queue.length - shown.length;
103 const groups = digestByDeal(shown)
104 .map(
105 (g) => `<li><a href="/deals/${g.deal_id}">${escape(g.address)}</a><ul>${g.drafts
106 .map(
107 (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>`,
108 )
109 .join("")}</ul></li>`,
110 )
111 .join("");
112 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>`;
90113 }
91114
92115 /** "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
536536 (n, deal) => n + waitingIn(`${day}|${JSON.stringify(agent)}|${print(deal)}`, () => todaysDrafts([deal], day, agent).filter(unsent).length),
537537 0,
538538 );
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) => {
170170 expect(made.status).toBe(201);
171171 for (let at = 0; at < 40; at++) {
172172 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 };
174175 }
175176 throw new Error("draft not found");
176177 };
src/routes.ts +4 −2
@@ -700,7 +700,8 @@ function sameDraft(c: Context, f: Record<string, string>, q: string, closing: Cl
700700 : -1;
701701 if (!queue.ok || same === -1) return undefined;
702702 const typed = { subject: f.subject ?? "", body: f.body ?? "", reason: f.reason ?? "", email: f.email ?? "", phone: f.phone ?? "" };
703 return reviewPage({ who: c.who, ...headerFor(c), agent: agentFor(c), queue: queue.value, q, closing, at: same, done: settled, error, typed });
703 const due = q === "" && closing === "" ? queue : reviewQueue(c, "", "");
704 return reviewPage({ who: c.who, ...headerFor(c), agent: agentFor(c), queue: queue.value, due: due.ok ? due.value : queue.value, q, closing, at: same, done: settled, error, typed });
704705 }
705706
706707 /**
@@ -1580,10 +1581,11 @@ export const routes: readonly Route[] = [
15801581 STATUS[queue.error],
15811582 clear,
15821583 );
1584 const due = q === "" && closing === "" ? queue : reviewQueue(c, "", "");
15831585 const sent = open === undefined ? undefined : listDeals(c.db, c.who);
15841586 const link = open === undefined || !sent?.ok ? undefined : sent.value.flatMap((d) => d.sent).find((m) => m.id === open)?.hrefs ?? [];
15851587 return html(
1586 reviewPage({ who: c.who, ...headerFor(c), agent: agentFor(c), queue: queue.value, q, closing, at: count(c.url.searchParams.get("at")), done: count(c.url.searchParams.get("done")), notice, open: link, settled: queue.value.length === 0 ? settledOn(c.db, agentFor(c).zone ?? DEFAULT_ZONE, dayOf(agentFor(c))) : undefined, next: queue.value.length === 0 ? nextAfter(c) : undefined }),
1588 reviewPage({ who: c.who, ...headerFor(c), agent: agentFor(c), queue: queue.value, due: due.ok ? due.value : queue.value, q, closing, at: count(c.url.searchParams.get("at")), done: count(c.url.searchParams.get("done")), notice, open: link, settled: queue.value.length === 0 ? settledOn(c.db, agentFor(c).zone ?? DEFAULT_ZONE, dayOf(agentFor(c))) : undefined, next: queue.value.length === 0 ? nextAfter(c) : undefined }),
15871589 200,
15881590 clear,
15891591 );
src/terminal.test.ts +34
@@ -12,6 +12,7 @@ import { canChange, setAppointment, shown, addDeadline, addParty, createDeal, ed
1212 import { openDb } from "./db.ts";
1313 import { clearSamples } from "./sample.ts";
1414 import { shiftPlan } from "./shift.ts";
15 import { DIGEST_MAX } from "./reminders.ts";
1516 import { terminal, type Terminal } from "./terminal.ts";
1617 import type { Who } from "./who.ts";
1718
@@ -1284,6 +1285,39 @@ test("K keeps a private note on the open deal, shows it in the deal header, clea
12841285 expect(deal().note).toBe("");
12851286 });
12861287
1288 test("Z lists today's due reminders by deal with their drafts, and sends nothing", () => {
1289 const t = terminal(db, alice, context);
1290 const before = deal().sent.length;
1291 t.press("Z");
1292 const text = lines(t).join("\n");
1293 expect(text).toContain("Due today");
1294 expect(text).toContain("12 Elm St");
1295 expect(text).toMatch(/to Ann Buyer \(buyer\)/);
1296 expect(text).not.toContain("Nothing is due today.");
1297 expect(deal().sent.length).toBe(before);
1298 });
1299
1300 test("Z cuts a long digest at the limit and says how many more r reminders reaches", () => {
1301 const big = openDb(mkdtempSync(join(tmpdir(), "app-terminal-digest-")));
1302 for (let i = 0; i < DIGEST_MAX + 5; i++) {
1303 const r = createDeal(big, alice, { openAnyway: true, address: `${i} Digest Ln`, buyer: "Ann Buyer", seller: "Sam Seller", contractDate: addDays(today(DEFAULT_ZONE), -20), closingDate: addDays(today(DEFAULT_ZONE), 2) });
1304 if (!r.ok) throw new Error(r.message);
1305 }
1306 const t = terminal(big, alice, context);
1307 t.press("Z");
1308 const text = lines(t).join("\n");
1309 const waiting = Number(text.match(/(\d+) waiting/)![1]);
1310 expect(waiting).toBeGreaterThan(DIGEST_MAX);
1311 expect(text).toContain(`And ${waiting - DIGEST_MAX} more`);
1312 expect(text.match(/ — to /g)!.length).toBe(DIGEST_MAX);
1313 });
1314
1315 test("Z says nothing is due today to a guest, who is shown no deals", () => {
1316 const t = terminal(db, { role: "anon", id: "", name: "" }, context);
1317 t.press("Z");
1318 expect(lines(t).join("\n")).toContain("Nothing is due today.");
1319 });
1320
12871321 test("the clock says what moved since the last look, drawn once per session, and a guest hears nothing", () => {
12881322 // A database of its own: the moves other tests made would be counted too.
12891323 const db = openDb(mkdtempSync(join(tmpdir(), "app-terminal-since-")));
src/terminal.ts +26 −3
@@ -88,7 +88,7 @@ import { IMPORT_BYTES, importText } from "./import.ts";
8888 import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
8989 import { join } from "node:path";
9090 import { coming, comingLine } from "./coming.ts";
91 import { drafts, heldFrom, inClockOrder, momentLabel, pending, remindsLine, sentLabel, unsent, type Agent, type Draft } from "./reminders.ts";
91 import { DIGEST_EMPTY, DIGEST_MAX, digestByDeal, drafts, heldFrom, inClockOrder, momentLabel, pending, remindsLine, sentLabel, unsent, type Agent, type Draft } from "./reminders.ts";
9292 import type { AuditRow } from "./audit.ts";
9393 import type { Result } from "./result.ts";
9494 import type { Who } from "./who.ts";
@@ -111,7 +111,7 @@ const ACTIVITY_ROWS = 20;
111111 const COLOUR: Readonly<Record<Urgency, string>> = { overdue: "31", soon: "33", later: "36", done: "2", waived: "2" };
112112 const tint = (u: Urgency, s: string): string => `${esc}${COLOUR[u]}m${s}${esc}0m`;
113113
114 type View = "dates" | "week" | "people" | "tour" | "reminders" | "deal" | "messages" | "timeline" | "file" | "profile" | "activity";
114 type View = "dates" | "week" | "people" | "tour" | "reminders" | "digest" | "deal" | "messages" | "timeline" | "file" | "profile" | "activity";
115115 interface Row {
116116 readonly deal: DealView;
117117 readonly date: DeadlineView;
@@ -726,6 +726,11 @@ export function terminal(
726726 cursor = 0;
727727 onlyMessages = false;
728728 trail = [];
729 } else if (key === "Z") {
730 view = "digest";
731 showHeld = false;
732 cursor = 0;
733 reading = undefined;
729734 } else if (key === "r") {
730735 view = "reminders";
731736 showHeld = false;
@@ -936,6 +941,22 @@ export function terminal(
936941 lines.push(` ${tint(o.urgency, `${spoken(o.due).padEnd(12)} ${relative(o.due, day).padEnd(12)}`)} ${o.label} — ${o.address}`);
937942 }
938943 }
944 } else if (view === "digest") {
945 const due = reminderRows()
946 .map((r) => r.draft)
947 .filter(unsent);
948 lines.push(bold("Due today"), "");
949 if (due.length === 0) lines.push(dim(DIGEST_EMPTY));
950 else {
951 lines.push(dim(`${due.length} waiting, by deal. Nothing is sent from here; r reminders sends.`), "");
952 for (const g of digestByDeal(due.slice(0, DIGEST_MAX))) {
953 lines.push(bold(g.address));
954 for (const d of g.drafts.map(edited)) {
955 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}`), "");
956 }
957 }
958 if (due.length > DIGEST_MAX) lines.push(dim(`And ${due.length - DIGEST_MAX} more: r reminders reaches every one.`));
959 }
939960 } else if (view === "tour") {
940961 const listed = listDeals(db, me);
941962 if (!listed.ok) lines.push(listed.message);
@@ -1075,6 +1096,8 @@ export function terminal(
10751096 : "d dates · q quit (only an admin changes the profile)"
10761097 : view === "tour"
10771098 ? "G tour · d dates · w this week · r reminders · P people · q quit"
1099 : view === "digest"
1100 ? "Z digest · r reminders · d dates · w this week · P people · q quit"
10781101 : view === "people"
10791102 ? "P people · d dates · w this week · r reminders · q quit"
10801103 : view === "reminders"
@@ -1087,7 +1110,7 @@ export function terminal(
10871110 ? "b back to the deal · d dates · q quit"
10881111 : view === "deal"
10891112 ? "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"
1090 : `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`;
1113 : `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`;
10911114 lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys));
10921115 }
10931116 return lines;
src/tour.page.test.ts +1 −1
@@ -20,7 +20,7 @@ const get = (path: string, headers: Record<string, string> = as("user")) => {
2020 forgetRationing();
2121 return fetch(base + path, { headers });
2222 };
23 const deals = async () => (await (await fetch(base + "/api/deals", { headers: as("admin") })).json()) as { id: number; address: string }[];
23 const deals = async () => (forgetRationing(), await (await fetch(base + "/api/deals", { headers: as("admin") })).json()) as { id: number; address: string }[];
2424
2525 test("the README tour is exactly the steps in src/tour.ts", () => {
2626 const md = readFileSync(new URL("../README.md", import.meta.url), "utf8");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs