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

opened this

Check · Review · Land · Live — Its check is red. Next: builder, its check is red.

Ready to land

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

  • Ready to merge — no conflicts with main. 3 commits will land on main.

    Check faileddeploy 1874791

    Website spec: 3 findings, 3 the edge owns (report only)
    • / no <link rel="canonical"> — canonical
    • / missing og:image — open-graph
    • / http://127.0.0.1:38631/tokens.css?v=2d14bc8d: a 2410-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:38631/static/app.css?v=6537e943: a 23359-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / no Cross-Origin-Opener-Policy — coop (the edge owns this)
    • / no ETag on the HTML — caching
    You need write access to merge.

Review

Sign in to review this change.
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.
8787 timeline and at the foot of every new reminder. (Only an admin can save
8888 it; **Use defaults** goes back to the sample agent.)
8989
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
9099 ## Holding a reminder for later
91100
92101 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 +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";
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";
@@ -86,7 +86,28 @@ export function reviewPage(input: {
8686 draft === undefined
8787 ? `<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>`
8888 : 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 {
outdated

Docblock for digest() sits above DIGEST_MAX; move it down onto function digest.

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>`;
90111 }
91112
92113 /** "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/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
12681268 expect(lines(guest).join("\n")).not.toContain("Private note");
12691269 expect(deal().note).toBe("");
12701270 });
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";
8787 import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
8888 import { join } from "node:path";
8989 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";
9191 import type { AuditRow } from "./audit.ts";
9292 import type { Result } from "./result.ts";
9393 import type { Who } from "./who.ts";
@@ -110,7 +110,7 @@ const ACTIVITY_ROWS = 20;
110110 const COLOUR: Readonly<Record<Urgency, string>> = { overdue: "31", soon: "33", later: "36", done: "2", waived: "2" };
111111 const tint = (u: Urgency, s: string): string => `${esc}${COLOUR[u]}m${s}${esc}0m`;
112112
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";
114114 interface Row {
115115 readonly deal: DealView;
116116 readonly date: DeadlineView;
@@ -723,6 +723,11 @@ export function terminal(
723723 cursor = 0;
724724 onlyMessages = false;
725725 trail = [];
726 } else if (key === "Z") {
727 view = "digest";
728 showHeld = false;
729 cursor = 0;
730 reading = undefined;
726731 } else if (key === "r") {
727732 view = "reminders";
728733 showHeld = false;
@@ -929,6 +934,22 @@ export function terminal(
929934 lines.push(` ${tint(o.urgency, `${spoken(o.due).padEnd(12)} ${relative(o.due, day).padEnd(12)}`)} ${o.label} — ${o.address}`);
930935 }
931936 }
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 }
932953 } else if (view === "tour") {
933954 const listed = listDeals(db, me);
934955 if (!listed.ok) lines.push(listed.message);
@@ -1068,6 +1089,8 @@ export function terminal(
10681089 : "d dates · q quit (only an admin changes the profile)"
10691090 : view === "tour"
10701091 ? "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"
10711094 : view === "people"
10721095 ? "P people · d dates · w this week · r reminders · q quit"
10731096 : view === "reminders"
@@ -1080,7 +1103,7 @@ export function terminal(
10801103 ? "b back to the deal · d dates · q quit"
10811104 : view === "deal"
10821105 ? "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`;
10841107 lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys));
10851108 }
10861109 return lines;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs