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
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/315-the-morning-tick-lists-the-reminders-due → main 6 commits fork 1 thread · 1 unresolved
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. | ||
| 92 | 92 | timeline and at the foot of every new reminder. (Only an admin can save |
| 93 | 93 | it; **Use defaults** goes back to the sample agent.) |
| 94 | 94 | |
| 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 | ||
| 95 | 104 | ## Holding a reminder for later |
| 96 | 105 | |
| 97 | 106 | 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 +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"; | ||
| 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"; |
| @@ -61,6 +61,8 @@ export function reviewPage(input: { | ||
| 61 | 61 | agent?: Agent | undefined; |
| 62 | 62 | /** The drafts still to send, in the clock's order. */ |
| 63 | 63 | 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; | |
| 64 | 66 | q: string; |
| 65 | 67 | closing?: ClosingFilter; |
| 66 | 68 | /** How many of the queue this round has skipped past. */ |
| @@ -86,7 +88,28 @@ export function reviewPage(input: { | ||
| 86 | 88 | draft === undefined |
| 87 | 89 | ? `<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 | 90 | : 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 | */ | |
| 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>`; | |
| 90 | 113 | } |
| 91 | 114 | |
| 92 | 115 | /** "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/routes.ts +4 −2
| @@ -700,7 +700,8 @@ function sameDraft(c: Context, f: Record<string, string>, q: string, closing: Cl | ||
| 700 | 700 | : -1; |
| 701 | 701 | if (!queue.ok || same === -1) return undefined; |
| 702 | 702 | 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 }); | |
| 704 | 705 | } |
| 705 | 706 | |
| 706 | 707 | /** |
| @@ -1580,10 +1581,11 @@ export const routes: readonly Route[] = [ | ||
| 1580 | 1581 | STATUS[queue.error], |
| 1581 | 1582 | clear, |
| 1582 | 1583 | ); |
| 1584 | const due = q === "" && closing === "" ? queue : reviewQueue(c, "", ""); | |
| 1583 | 1585 | const sent = open === undefined ? undefined : listDeals(c.db, c.who); |
| 1584 | 1586 | const link = open === undefined || !sent?.ok ? undefined : sent.value.flatMap((d) => d.sent).find((m) => m.id === open)?.hrefs ?? []; |
| 1585 | 1587 | 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 }), | |
| 1587 | 1589 | 200, |
| 1588 | 1590 | clear, |
| 1589 | 1591 | ); |
src/terminal.test.ts +34
| @@ -12,6 +12,7 @@ import { canChange, setAppointment, shown, addDeadline, addParty, createDeal, ed | ||
| 12 | 12 | import { openDb } from "./db.ts"; |
| 13 | 13 | import { clearSamples } from "./sample.ts"; |
| 14 | 14 | import { shiftPlan } from "./shift.ts"; |
| 15 | import { DIGEST_MAX } from "./reminders.ts"; | |
| 15 | 16 | import { terminal, type Terminal } from "./terminal.ts"; |
| 16 | 17 | import type { Who } from "./who.ts"; |
| 17 | 18 | |
| @@ -1284,6 +1285,39 @@ test("K keeps a private note on the open deal, shows it in the deal header, clea | ||
| 1284 | 1285 | expect(deal().note).toBe(""); |
| 1285 | 1286 | }); |
| 1286 | 1287 | |
| 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 | ||
| 1287 | 1321 | test("the clock says what moved since the last look, drawn once per session, and a guest hears nothing", () => { |
| 1288 | 1322 | // A database of its own: the moves other tests made would be counted too. |
| 1289 | 1323 | 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"; | ||
| 88 | 88 | import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; |
| 89 | 89 | import { join } from "node:path"; |
| 90 | 90 | 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"; | |
| 92 | 92 | import type { AuditRow } from "./audit.ts"; |
| 93 | 93 | import type { Result } from "./result.ts"; |
| 94 | 94 | import type { Who } from "./who.ts"; |
| @@ -111,7 +111,7 @@ const ACTIVITY_ROWS = 20; | ||
| 111 | 111 | const COLOUR: Readonly<Record<Urgency, string>> = { overdue: "31", soon: "33", later: "36", done: "2", waived: "2" }; |
| 112 | 112 | const tint = (u: Urgency, s: string): string => `${esc}${COLOUR[u]}m${s}${esc}0m`; |
| 113 | 113 | |
| 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"; | |
| 115 | 115 | interface Row { |
| 116 | 116 | readonly deal: DealView; |
| 117 | 117 | readonly date: DeadlineView; |
| @@ -726,6 +726,11 @@ export function terminal( | ||
| 726 | 726 | cursor = 0; |
| 727 | 727 | onlyMessages = false; |
| 728 | 728 | trail = []; |
| 729 | } else if (key === "Z") { | |
| 730 | view = "digest"; | |
| 731 | showHeld = false; | |
| 732 | cursor = 0; | |
| 733 | reading = undefined; | |
| 729 | 734 | } else if (key === "r") { |
| 730 | 735 | view = "reminders"; |
| 731 | 736 | showHeld = false; |
| @@ -936,6 +941,22 @@ export function terminal( | ||
| 936 | 941 | lines.push(` ${tint(o.urgency, `${spoken(o.due).padEnd(12)} ${relative(o.due, day).padEnd(12)}`)} ${o.label} — ${o.address}`); |
| 937 | 942 | } |
| 938 | 943 | } |
| 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 | } | |
| 939 | 960 | } else if (view === "tour") { |
| 940 | 961 | const listed = listDeals(db, me); |
| 941 | 962 | if (!listed.ok) lines.push(listed.message); |
| @@ -1075,6 +1096,8 @@ export function terminal( | ||
| 1075 | 1096 | : "d dates · q quit (only an admin changes the profile)" |
| 1076 | 1097 | : view === "tour" |
| 1077 | 1098 | ? "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" | |
| 1078 | 1101 | : view === "people" |
| 1079 | 1102 | ? "P people · d dates · w this week · r reminders · q quit" |
| 1080 | 1103 | : view === "reminders" |
| @@ -1087,7 +1110,7 @@ export function terminal( | ||
| 1087 | 1110 | ? "b back to the deal · d dates · q quit" |
| 1088 | 1111 | : view === "deal" |
| 1089 | 1112 | ? "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`; | |
| 1091 | 1114 | lines.push(dim(me.role === "anon" ? `${keys} · sign in with a key the forge knows to change anything` : keys)); |
| 1092 | 1115 | } |
| 1093 | 1116 | return lines; |
src/tour.page.test.ts +1 −1
| @@ -20,7 +20,7 @@ const get = (path: string, headers: Record<string, string> = as("user")) => { | ||
| 20 | 20 | forgetRationing(); |
| 21 | 21 | return fetch(base + path, { headers }); |
| 22 | 22 | }; |
| 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 }[]; | |
| 24 | 24 | |
| 25 | 25 | test("the README tour is exactly the steps in src/tour.ts", () => { |
| 26 | 26 | const md = readFileSync(new URL("../README.md", import.meta.url), "utf8"); |
new above
offline
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.