Public: anyone can read this; the crew can change it.

A held reminder can come back on a day the agent picks, and shows on Today's reminders that morning #299

Merged opened this

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

Merged

mason/deal-clock:mason/292-a-held-reminder-can-come-back-on-a-day-t → main 5 commits fork

  • Merged. mason/292-a-held-reminder-can-come-back-on-a-day-t was merged into main as 9bacaa2.

Review

Sign in to review this change.
17 files changed +206 −43
README.md +8
@@ -87,6 +87,14 @@ 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 ## Holding a reminder for later
91
92 On the review page, **Tomorrow** holds a draft until the next morning. To hold
93 it longer, pick a "Bring back on" date (today or later, up to a year ahead)
94 first. It stays out of the count and the list until that morning, then returns
95 under Today's reminders marked "Back from hold", with your edited words. The
96 deal's page says "Returns" and the date while it waits. In the terminal, **h** on a reminder asks for the date; leave it blank for tomorrow.
97
9098 ## How people like to be reached
9199
92100 A realtor knows who answers texts and who only reads email. When you add a
contract/openapi.json +6 −2
@@ -2096,7 +2096,7 @@
20962096 },
20972097 "/api/deals/{id}/reminders/hold": {
20982098 "post": {
2099 "summary": "Hold a reminder until tomorrow: it leaves today's list and /tick, and comes back the next morning with its words. Give subject (up to 150 characters) and body (up to 2000) to hold the agent's edited words; leave them out to keep any words already held. Held again, it waits one more day. A status update is not held.",
2099 "summary": "Hold a reminder until tomorrow, or until back_on (today to a year ahead): it leaves today's list and /tick, and comes back the next morning with its words. Give subject (up to 150 characters) and body (up to 2000) to hold the agent's edited words; leave them out to keep any words already held. Held again, it waits one more day. A status update is not held.",
21002100 "operationId": "postApiDealsIdRemindersHold",
21012101 "parameters": [
21022102 {
@@ -2140,6 +2140,10 @@
21402140 "type": "string",
21412141 "minLength": 1,
21422142 "maxLength": 2000
2143 },
2144 "back_on": {
2145 "type": "string",
2146 "format": "date"
21432147 }
21442148 },
21452149 "required": [
@@ -2153,7 +2157,7 @@
21532157 },
21542158 "responses": {
21552159 "201": {
2156 "description": "Hold a reminder until tomorrow: it leaves today's list and /tick, and comes back the next morning with its words. Give subject (up to 150 characters) and body (up to 2000) to hold the agent's edited words; leave them out to keep any words already held. Held again, it waits one more day. A status update is not held.",
2160 "description": "Hold a reminder until tomorrow, or until back_on (today to a year ahead): it leaves today's list and /tick, and comes back the next morning with its words. Give subject (up to 150 characters) and body (up to 2000) to hold the agent's edited words; leave them out to keep any words already held. Held again, it waits one more day. A status update is not held.",
21572161 "content": {
21582162 "application/json": {
21592163 "schema": {
docs/TECHNICAL.md +1 −1
@@ -151,7 +151,7 @@ reminder (`deadline_id`, `party_id`, `moment`, `due`, `move_id`) with the mornin
151151 returns (`until`). It leaves `todaysDrafts`, the waiting count and `/tick` until then,
152152 returns at the top as "Held from <weekday>", and holding again pushes `until` one more
153153 day. Posts: `/reminders/hold`, `/reminders/today/hold`, `POST /api/deals/:id/reminders/hold`;
154 MCP `hold_reminder`; in the terminal, `h` on the reminders list. A reminder can be held only from its send morning on. Audited as `reminder.hold:<until>`. Same `deal` update cell as Send.
154 MCP `hold_reminder`; in the terminal, `h` on the reminders list, which asks for an optional bring-back date (blank holds until tomorrow). The page, API (`back_on`) and MCP take the same date: today to a year ahead, stored in `reminder_hold.back_on`. A reminder can be held only from its send morning on. Audited as `reminder.hold:<until>`. Same `deal` update cell as Send.
155155
156156 Bringing one back: in the terminal, `H` on the reminders list shows the held ones and `B` brings one back; `POST /reminders/release` (the Bring back button on the deal's Messages list), `DELETE /api/deals/:id/reminders/hold`, MCP `release_reminder`. It deletes the hold; a reminder that is not held, sent or declined is a 409. Audited as `reminder.release`. Same `deal` update cell.
157157
src/db.ts +2
@@ -574,6 +574,8 @@ export const migrations: string[] = [
574574 `ALTER TABLE deadline ADD COLUMN place TEXT CHECK (place IS NULL OR length(place) BETWEEN 1 AND 200)`,
575575 // How a party likes to be reached: '' for no preference, else text, email or call.
576576 `ALTER TABLE party ADD COLUMN reach TEXT NOT NULL DEFAULT '' CHECK (reach IN ('', 'text', 'email', 'call'))`,
577 // The day the agent chose for a held reminder to come back; NULL for a plain "tomorrow" hold.
578 `ALTER TABLE reminder_hold ADD COLUMN back_on TEXT`,
577579 ];
578580
579581 export function openDb(dir = dataDir): Database {
src/deals.ts +11 −6
@@ -128,6 +128,8 @@ export interface Held {
128128 /** The words held with it; '' while none were. */
129129 readonly subject: string;
130130 readonly body: string;
131 /** The day the agent chose for it to come back; null for a hold until tomorrow. */
132 readonly back_on: string | null;
131133 }
132134 export interface Sent {
133135 readonly id: number;
@@ -1430,7 +1432,7 @@ export function declineReminder(
14301432 export function holdReminder(
14311433 db: Database,
14321434 who: Who,
1433 input: { deadlineId: number; dealId?: number; partyId: number; moment: unknown; subject?: unknown; body?: unknown; agent?: Agent; on?: string },
1435 input: { deadlineId: number; dealId?: number; partyId: number; moment: unknown; subject?: unknown; body?: unknown; agent?: Agent; on?: string; backOn?: unknown },
14341436 ): Result<Held> {
14351437 const { dealId, found } = draftOf(db, who, input);
14361438 if (!found.ok) return within(db, who, "reminder.hold", dealId, found);
@@ -1450,14 +1452,17 @@ export function holdReminder(
14501452 const body = input.body === undefined ? undefined : message(input.body, 1, BODY_MAX);
14511453 if (input.body !== undefined && body === undefined)
14521454 return within(db, who, "reminder.hold", dealId, fail("invalid", `The message is 1 to ${BODY_MAX} characters.`));
1453 const until = addDays(draft.held !== null && draft.held.until > day ? draft.held.until : day, 1);
1455 const asked = input.backOn === undefined || input.backOn === null || input.backOn === "" ? undefined : input.backOn;
1456 if (asked !== undefined && !(isDate(asked) && asked >= day && asked <= addDays(day, 365)))
1457 return within(db, who, "reminder.hold", dealId, fail("invalid", "Bring it back on a date from today to a year from today."));
1458 const until = asked ?? addDays(draft.held !== null && draft.held.until > day ? draft.held.until : day, 1);
14541459 const held = db
1455 .query<Held, [number, number, number, string, string, number, string, string, string, string, string]>(
1456 `INSERT INTO reminder_hold (deal_id, deadline_id, party_id, moment, due, move_id, until, held_on, by_name, subject, body) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1457 ON CONFLICT (deadline_id, party_id, moment, due, move_id) DO UPDATE SET until = excluded.until, held_on = excluded.held_on, by_name = excluded.by_name,
1460 .query<Held, [number, number, number, string, string, number, string, string, string, string, string, string | null]>(
1461 `INSERT INTO reminder_hold (deal_id, deadline_id, party_id, moment, due, move_id, until, held_on, by_name, subject, body, back_on) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
1462 ON CONFLICT (deadline_id, party_id, moment, due, move_id) DO UPDATE SET until = excluded.until, back_on = excluded.back_on, held_on = excluded.held_on, by_name = excluded.by_name,
14581463 subject = CASE WHEN ?10 = '' THEN subject ELSE ?10 END, body = CASE WHEN ?11 = '' THEN body ELSE ?11 END, at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') RETURNING *`,
14591464 )
1460 .get(deal.id, draft.deadline_id, draft.party_id, draft.moment, draft.due, draft.move_id, until, day, authorOf(who).name, subject ?? "", body ?? "")!;
1465 .get(deal.id, draft.deadline_id, draft.party_id, draft.moment, draft.due, draft.move_id, until, day, authorOf(who).name, subject ?? "", body ?? "", asked ?? null)!;
14611466 return within(db, who, `reminder.hold:${until}`, dealId, ok(held));
14621467 }
14631468
src/holddate.test.ts added +113
@@ -0,0 +1,113 @@
1 /** A reminder held until a day the agent picks: hidden before it, back on it, refused when the date is wrong. */
2 import { afterAll, beforeEach, expect, test } from "bun:test";
3 import { Database } from "bun:sqlite";
4 import { mkdtempSync } from "node:fs";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { addDays, today, DEFAULT_ZONE } from "./dates.ts";
8 import { migrate, migrations, openDb } from "./db.ts";
9 import { createDeal, getDeal, holdReminder, type DealView } from "./deals.ts";
10 import { todaysDrafts, waitingCount } from "./reminders.ts";
11 import { forgetRationing } from "./security.ts";
12 import { serve } from "./server.ts";
13 import { settledLine, settledOn } from "./settled.ts";
14 import { sign } from "./who.ts";
15 import type { Who } from "./who.ts";
16
17 const db = openDb(mkdtempSync(join(tmpdir(), "app-holddate-")));
18 const alice: Who = { role: "user", id: "u1", name: "alice" };
19 const agent = { name: "Dana Whitfield", brokerage: "Harbor & Pine Realty", phone: "(555) 010-0142", email: "[email protected]" };
20 const now = today(DEFAULT_ZONE);
21 const made = createDeal(db, alice, { openAnyway: true, address: "9 Later Ln", buyer: "Ann Buyer", seller: "Sam Seller", contractDate: addDays(now, -5), closingDate: addDays(now, 40), lengths: { inspection: "8" } });
22 if (!made.ok) throw new Error(made.message);
23 const fresh = (): DealView => {
24 const got = getDeal(db, alice, { id: made.value.id });
25 if (!got.ok) throw new Error(got.message);
26 return got.value;
27 };
28 const inspection = made.value.deadlines.find((d) => d.kind === "inspection")!;
29 const first = todaysDrafts([fresh()], now, agent).find((d) => d.deadline_id === inspection.id && d.moment === "before")!;
30 const ask = { dealId: made.value.id, deadlineId: first.deadline_id, partyId: first.party_id, moment: first.moment };
31 const listed = (day: string) => todaysDrafts([fresh()], day, agent).filter((d) => d.deadline_id === first.deadline_id && d.party_id === first.party_id && d.moment === "before");
32 const later = addDays(now, 2);
33
34 test("a dated hold is out of the list and the count until its day, then back marked, with its words", () => {
35 const before = waitingCount([fresh()], now, agent);
36 const held = holdReminder(db, alice, { ...ask, subject: "Edited", body: "Edited words", backOn: later });
37 expect(held.ok && held.value.until).toBe(later);
38 expect(held.ok && held.value.back_on).toBe(later);
39 expect(waitingCount([fresh()], now, agent)).toBe(before - 1);
40 for (const day of [now, addDays(now, 1), addDays(later, -1)]) expect(listed(day)).toEqual([]);
41 const back = listed(later);
42 expect(back.length).toBe(1);
43 expect(back[0]!.held?.back_on).toBe(later);
44 expect(back[0]!.subject).toBe("Edited");
45 expect(back[0]!.body).toBe("Edited words");
46 });
47
48 test("without a date the hold is the same as ever, and holding again without one clears the date", () => {
49 const held = holdReminder(db, alice, ask);
50 // Held again, it waits one more day past the dated hold it replaces.
51 const day = addDays(later, 1);
52 expect(held.ok && held.value.until).toBe(day);
53 expect(held.ok && held.value.back_on).toBeNull();
54 expect(listed(day)[0]!.held?.back_on).toBeUndefined();
55 });
56
57 test("a past, far, or garbage date is refused and holds nothing", () => {
58 const rows = (): number => db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_hold").get()!.n;
59 const n = rows();
60 for (const bad of [addDays(now, -1), addDays(now, 366), "2026-02-30", "tomorrow", 5, "2026-1-1"]) {
61 const r = holdReminder(db, alice, { ...ask, backOn: bad });
62 expect(r.ok).toBe(false);
63 }
64 expect(rows()).toBe(n);
65 expect(holdReminder(db, alice, { ...ask, backOn: addDays(now, 365) }).ok).toBe(true);
66 expect(holdReminder(db, { role: "anon" }, { ...ask, backOn: later }).ok).toBe(false);
67 });
68
69 test("the finished screen counts dated holds apart from tomorrow's", () => {
70 holdReminder(db, alice, { ...ask, backOn: later });
71 const s = settledOn(db, DEFAULT_ZONE, now);
72 expect(s.dated).toBeGreaterThan(0);
73 expect(settledLine({ sent: 0, declined: 0, held: 0, dated: 2 })).toBe("2 held until a day you chose.");
74 expect(settledLine({ sent: 0, declined: 0, held: 1 })).toBe("1 held until tomorrow.");
75 });
76
77 test("a migration adds the column and leaves old held rows held with no date", () => {
78 const old = new Database(":memory:");
79 migrate(old);
80 old.exec("ALTER TABLE reminder_hold DROP COLUMN back_on");
81 old.query("DELETE FROM schema_version WHERE version = ?1").run(migrations.length);
82 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 migrate(old);
84 expect(old.query("SELECT until, back_on FROM reminder_hold").all()).toEqual([{ until: "2026-11-12", back_on: null }]);
85 });
86
87 // ---- through the door ----
88
89 const KEY = "a-key-only-the-forge-holds";
90 const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-holddate-web-")), identityKey: KEY });
91 const base = `http://${server.hostname}:${server.port}`;
92 afterAll(() => server.stop(true));
93 beforeEach(forgetRationing);
94 const identity = `u1|alice|user|${Date.now() + 600_000}`;
95 const signed = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
96 const post = (fields: Record<string, string>, headers: Record<string, string> = signed) =>
97 fetch(`${base}/reminders/today/hold`, { method: "POST", body: new URLSearchParams(fields), headers: { ...headers, "content-type": "application/x-www-form-urlencoded" }, redirect: "manual" });
98
99 test("the review page offers the date; a foreign origin and a bad date are refused, a good one holds", async () => {
100 const page = await (await fetch(`${base}/reminders/today`, { headers: signed })).text();
101 expect(page).toContain('name="back_on"');
102 const hidden = (name: string) => new RegExp(`name="${name}" value="([^"]*)"`).exec(page)![1]!;
103 const keys = { deadline: hidden("deadline"), party: hidden("party"), moment: hidden("moment"), at: "0", done: "0", q: "" };
104 const day = today(DEFAULT_ZONE);
105 expect((await post({ ...keys, back_on: addDays(day, 3) }, { ...signed, origin: "https://evil.example" })).status).toBeGreaterThanOrEqual(400);
106 const bad = await post({ ...keys, back_on: "2001-01-01" });
107 expect(bad.status).toBe(422);
108 expect(await post({ ...keys, back_on: "nope" }).then((r) => r.status)).toBe(422);
109 const ok = await post({ ...keys, back_on: addDays(day, 3) });
110 expect(ok.status).toBe(303);
111 const done = await (await fetch(`${base}/reminders/today?q=zzzz-none&done=1`, { headers: signed })).text();
112 expect(done).toContain("held until a day you chose.");
113 });
src/mcp.ts +3 −3
@@ -319,14 +319,14 @@ export const tools: readonly Tool[] = [
319319 {
320320 name: "hold_reminder",
321321 description:
322 "Hold a reminder until tomorrow, for a message the agent is not ready to approve: it leaves reminders_today and comes back the next morning with its words, labelled held. Give subject (up to 150 characters) and body (up to 2000) to hold the agent's edited words; leave them out to keep any already held. Held again, it waits one more day. A status update is not held.",
322 "Hold a reminder until tomorrow, for a message the agent is not ready to approve: it leaves reminders_today and comes back the next morning with its words, labelled held. Give subject (up to 150 characters) and body (up to 2000) to hold the agent's edited words; leave them out to keep any already held. Held again, it waits one more day. Give back_on to bring it back on that day instead of tomorrow. A status update is not held.",
323323 inputSchema: {
324324 type: "object",
325 properties: { deadline_id: { type: "integer" }, party_id: { type: "integer" }, moment: { enum: ["before", "day_of", "late", "moved", "checkin", "anniversary"] }, subject: { type: "string" }, body: { type: "string" } },
325 properties: { deadline_id: { type: "integer" }, party_id: { type: "integer" }, moment: { enum: ["before", "day_of", "late", "moved", "checkin", "anniversary"] }, subject: { type: "string" }, body: { type: "string" }, back_on: { type: "string", description: "YYYY-MM-DD, today to a year ahead" } },
326326 required: ["deadline_id", "party_id", "moment"],
327327 },
328328 run: (db, who, args) =>
329 holdReminder(db, who, { deadlineId: Number(args.deadline_id), partyId: Number(args.party_id), moment: args.moment, subject: args.subject, body: args.body }),
329 holdReminder(db, who, { deadlineId: Number(args.deadline_id), partyId: Number(args.party_id), moment: args.moment, subject: args.subject, body: args.body, backOn: args.back_on }),
330330 },
331331 {
332332 name: "release_reminder",
src/migrations.pin.json +2 −1
@@ -58,5 +58,6 @@
5858 "429a2adfb36ff61def2d1979556a9db87f4758663fa9c56b97e599b3bc13b773",
5959 "c5df0d263e04d4e07f9df383beb57f033966af540bb228d9bbbd7b57f3620408",
6060 "4a52d9de76ede6d34d831d9bc6fc1d3d3a501a5d3652bf63695a9de49f2a7d39",
61 "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3"
61 "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3",
62 "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5"
6263 ]
src/migrations.test.ts +1 −1
@@ -13,7 +13,7 @@ test("every migration is pinned by position; a pull may only append", () => {
1313 });
1414
1515 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/.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/.test(m));
1717 const db = new Database(":memory:");
1818 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))");
1919 before.forEach((sql, i) => {
src/pages/deal.ts +1 −1
@@ -135,7 +135,7 @@ function messages(deal: DealView, may: boolean, agent: Agent, to: string): strin
135135 const waiting = holding.length === 0
136136 ? ""
137137 : `<ul class="drafts">${holding
138 .map(({ d, held }) => `<li class="draft"><span><b>${escape(d.party_name)}</b> <span class="muted">· ${escape(d.label)}, ${escape(short(d.due))} · ${escape(sentLabel(d.moment))}</span></span><span class="state">Held until ${escape(spoken(held.until))} by ${escape(held.by_name || "someone")}</span>${may ? releaseForm(d, to) : ""}</li>`)
138 .map(({ d, held }) => `<li class="draft"><span><b>${escape(d.party_name)}</b> <span class="muted">· ${escape(d.label)}, ${escape(short(d.due))} · ${escape(sentLabel(d.moment))}</span></span><span class="state">${held.back_on === undefined ? "Held until" : "Returns"} ${escape(spoken(held.until))} by ${escape(held.by_name || "someone")}</span>${may ? releaseForm(d, to) : ""}</li>`)
139139 .join("")}</ul>`;
140140 if (deal.sent.length === 0) return `${waiting}<p class="muted">Nothing sent yet</p>`;
141141 const live = new Set(deal.deadlines.map((d) => d.id));
src/pages/review.ts +5 −4
@@ -9,7 +9,7 @@
99 import type { Agent } from "../reminders.ts";
1010 import type { Next } from "../coming.ts";
1111 import { BODY_MAX, EMAIL_MAX, PHONE_MAX, REASON_MAX, reachLine, SUBJECT_MAX } from "../deals.ts";
12 import { spoken } from "../dates.ts";
12 import { addDays, dayOf, spoken } from "../dates.ts";
1313 import { PARTY_LABEL } from "../deadlines.ts";
1414 import { asset, escape } from "../http.ts";
1515 import { heldFrom, momentLabel, type Draft } from "../reminders.ts";
@@ -80,7 +80,7 @@ export function reviewPage(input: {
8080 const body =
8181 draft === undefined
8282 ? `<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>`
83 : form(draft, { q, at, done, position: done + at + 1, total: done + queue.length, error, typed }, back);
83 : form(draft, { q, at, done, position: done + at + 1, total: done + queue.length, error, typed, on: dayOf(agent ?? {}) }, back);
8484 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 });
8585 }
8686
@@ -106,10 +106,10 @@ function contactField(typed: Typed, problem: string, invalid: string): string {
106106
107107 function form(
108108 draft: Draft,
109 s: { q: string; at: number; done: number; position: number; total: number; error: ReviewError | undefined; typed: Typed | undefined },
109 s: { q: string; at: number; done: number; position: number; total: number; error: ReviewError | undefined; typed: Typed | undefined; on: string },
110110 back: string,
111111 ): string {
112 const { error } = s;
112 const { error, on } = s;
113113 const typed = s.typed ?? { subject: draft.subject, body: draft.body, reason: "" };
114114 const keys = { deadline: draft.deadline_id, party: draft.party_id, moment: draft.moment, at: s.at, done: s.done, q: s.q };
115115 const problem = (field: ReviewField): string =>
@@ -133,6 +133,7 @@ function form(
133133 <button class="button" data-key="s" aria-keyshortcuts="S">Send</button>
134134 ${draft.contact === "" ? '<span class="muted small">No contact on file: Send records the approval and your Mail opens with no recipient.</span>' : ""}
135135 ${draft.moment === "status" ? "" : `<button class="button button-quiet" data-key="t" aria-keyshortcuts="T" formaction="${REVIEW_PATH}/hold">Tomorrow</button>`}
136 ${draft.moment === "status" ? "" : `<label class="small">Bring back on <span class="muted">(optional)</span><input class="input" type="date" name="back_on" min="${escape(on)}" max="${escape(addDays(on, 365))}"></label>`}
136137 </form>
137138 <form class="move" method="post" action="${REVIEW_PATH}/decline">${hidden(keys)}
138139 ${problem("reason")}
src/reach.test.ts +2 −2
@@ -56,8 +56,8 @@ test("someone signed out may not set it", () => {
5656
5757 test("the migration adds the column to a database that has parties, losing none", () => {
5858 const db = new Database(":memory:");
59 const last = migrations.length - 1;
60 expect(migrations[last]).toContain("party ADD COLUMN reach");
59 const last = migrations.findIndex((m) => m.includes("party ADD COLUMN reach"));
60 expect(last).toBeGreaterThan(0);
6161 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))");
6262 migrations.slice(0, last).forEach((sql, i) => {
6363 try { db.exec(sql); } catch (e) { if (!String(e).includes("duplicate column name")) throw e; }
src/reminders.ts +5 −5
@@ -62,7 +62,7 @@ export interface Draft {
6262 /** Why the agent chose not to send it, and who; null while it stands. */
6363 readonly declined: { readonly reason: string; readonly by_name: string } | null;
6464 /** Held by the agent until a later morning, and who held it; null while it is not (and once sent or declined). */
65 readonly held: { readonly until: string; readonly from: string; readonly by_name: string } | null;
65 readonly held: { readonly until: string; readonly from: string; readonly by_name: string; /** Set when the agent chose the day it came back. */ readonly back_on?: string } | null;
6666 }
6767
6868 export const MOMENTS: readonly Moment[] = ["before", "day_of", "late", "moved"];
@@ -255,7 +255,7 @@ export function draftsFor(deal: DealView, deadline: DeadlineView, agent: Agent):
255255 sent_at: sent?.sent_at ?? null,
256256 sent_on: sent === undefined ? null : dayOfStamp(deal.zone, sent.sent_at),
257257 declined: declined === undefined ? null : { reason: declined.reason, by_name: declined.by_name },
258 held: held === undefined ? null : { until: held.until, from: held.held_on, by_name: held.by_name },
258 held: held === undefined ? null : { until: held.until, from: held.held_on, by_name: held.by_name, ...(held.back_on === null ? {} : { back_on: held.back_on }) },
259259 }];
260260 }),
261261 );
@@ -306,7 +306,7 @@ export function checkinDrafts(deal: DealView, agent: Agent): Draft[] {
306306 sent_at: sent?.sent_at ?? null,
307307 sent_on: sent === undefined ? null : dayOfStamp(deal.zone, sent.sent_at),
308308 declined: declined === undefined ? null : { reason: declined.reason, by_name: declined.by_name },
309 held: held === undefined ? null : { until: held.until, from: held.held_on, by_name: held.by_name },
309 held: held === undefined ? null : { until: held.until, from: held.held_on, by_name: held.by_name, ...(held.back_on === null ? {} : { back_on: held.back_on }) },
310310 };
311311 });
312312 }
@@ -356,7 +356,7 @@ export function anniversaryDrafts(deal: DealView, agent: Agent): Draft[] {
356356 sent_at: sent?.sent_at ?? null,
357357 sent_on: sent === undefined ? null : dayOfStamp(deal.zone, sent.sent_at),
358358 declined: declined === undefined ? null : { reason: declined.reason, by_name: declined.by_name },
359 held: held === undefined ? null : { until: held.until, from: held.held_on, by_name: held.by_name },
359 held: held === undefined ? null : { until: held.until, from: held.held_on, by_name: held.by_name, ...(held.back_on === null ? {} : { back_on: held.back_on }) },
360360 };
361361 }),
362362 );
@@ -510,7 +510,7 @@ export const todaysDrafts = (deals: readonly DealView[], day: string, agent: Age
510510 const returned = (d: Draft): boolean => d.held !== null;
511511
512512 /** "Held from Monday": what a reminder that came back says it was. */
513 export const heldFrom = (held: NonNullable<Draft["held"]>): string => `Held from ${weekday(held.from)}`;
513 export const heldFrom = (held: NonNullable<Draft["held"]>): string => (held.back_on === undefined ? `Held from ${weekday(held.from)}` : "Back from hold");
514514
515515 const waitingIn = memoizer<number>(400);
516516
src/routes.ts +7 −6
@@ -39,7 +39,7 @@ import {
3939 } from "./http.ts";
4040 import type { Verb } from "./can.ts";
4141 import { events } from "./live.ts";
42 import { DEFAULT_ZONE, dayOf } from "./dates.ts";
42 import { DEFAULT_ZONE, dayOf, isDate, spoken } from "./dates.ts";
4343 import { nextComing, type Next } from "./coming.ts";
4444 import { tally } from "./book.ts";
4545 import { AMOUNT_MAX, RATE_MAX } from "./money.ts";
@@ -647,12 +647,13 @@ const fieldOf = (message: string): ReviewField => (/^The subject/.test(message)
647647 * sheet uses. Done, the page shows the next unsent draft; refused for what was
648648 * typed, it shows the same draft again with the words kept.
649649 */
650 async function reviewed(c: Context, act: (f: Record<string, string>) => Result<{ id: number }>, said: string): Promise<Response> {
650 async function reviewed(c: Context, act: (f: Record<string, string>) => Result<{ id: number }>, saying: string | ((f: Record<string, string>) => string)): Promise<Response> {
651651 const f = await form(c);
652652 if (f instanceof Response) return f;
653653 const q = query(f.q ?? "") ?? "";
654654 const at = count(f.at);
655655 const settled = count(f.done);
656 const said = typeof saying === "string" ? saying : saying(f);
656657 const result = act(f);
657658 if (result.ok) return back(said, reviewHref(q, at, settled + 1), said === SENT ? result.value.id : undefined);
658659 if (result.error === "signin_required" || result.error === "forbidden")
@@ -1466,7 +1467,7 @@ export const routes: readonly Route[] = [
14661467 {
14671468 method: "POST",
14681469 path: "/reminders/today/hold",
1469 handle: (c) => reviewed(c, (f) => holdReminder(c.db, c.who, { deadlineId: Number(f.deadline), partyId: Number(f.party), moment: f.moment, subject: f.subject, body: f.body }), HELD),
1470 handle: (c) => reviewed(c, (f) => holdReminder(c.db, c.who, { deadlineId: Number(f.deadline), partyId: Number(f.party), moment: f.moment, subject: f.subject, body: f.body, backOn: f.back_on }), (f) => (isDate(f.back_on) ? `Held until ${spoken(f.back_on)}.` : HELD)),
14701471 },
14711472 {
14721473 method: "POST",
@@ -1745,14 +1746,14 @@ export const routes: readonly Route[] = [
17451746 const body = await readJson(c.request);
17461747 if (body === undefined) return json({ error: "too_large" }, 413);
17471748 const f = fields(body);
1748 return answer(holdReminder(c.db, c.who, { dealId: Number(c.params.id), deadlineId: Number(f.deadline_id), partyId: Number(f.party_id), moment: f.moment, subject: f.subject, body: f.body }), 201);
1749 return answer(holdReminder(c.db, c.who, { dealId: Number(c.params.id), deadlineId: Number(f.deadline_id), partyId: Number(f.party_id), moment: f.moment, subject: f.subject, body: f.body, backOn: f.back_on }), 201);
17491750 },
17501751 api: {
17511752 summary:
1752 "Hold a reminder until tomorrow: it leaves today's list and /tick, and comes back the next morning with its words. Give subject (up to 150 characters) and body (up to 2000) to hold the agent's edited words; leave them out to keep any words already held. Held again, it waits one more day. A status update is not held.",
1753 "Hold a reminder until tomorrow, or until back_on (today to a year ahead): it leaves today's list and /tick, and comes back the next morning with its words. Give subject (up to 150 characters) and body (up to 2000) to hold the agent's edited words; leave them out to keep any words already held. Held again, it waits one more day. A status update is not held.",
17531754 body: {
17541755 type: "object",
1755 properties: { deadline_id: { type: "integer" }, party_id: { type: "integer" }, moment: { enum: ["before", "day_of", "late", "moved", "checkin", "anniversary"] }, subject: { type: "string", minLength: 1, maxLength: 150 }, body: { type: "string", minLength: 1, maxLength: 2000 } },
1756 properties: { deadline_id: { type: "integer" }, party_id: { type: "integer" }, moment: { enum: ["before", "day_of", "late", "moved", "checkin", "anniversary"] }, subject: { type: "string", minLength: 1, maxLength: 150 }, body: { type: "string", minLength: 1, maxLength: 2000 }, back_on: { type: "string", format: "date" } },
17561757 required: ["deadline_id", "party_id", "moment"],
17571758 },
17581759 answer: { type: "object", properties: { id: { type: "integer" }, until: { type: "string" }, by_name: { type: "string" } }, required: ["id", "until"] },
src/settled.ts +7 −3
@@ -12,6 +12,8 @@ export interface Settled {
1212 readonly declined: number;
1313 /** Held today and still held: a release deletes the hold. */
1414 readonly held: number;
15 /** Of those held today, the ones the agent gave a day to come back on. */
16 readonly dated?: number;
1517 }
1618
1719 /** Records written in the last two days, read in one query, then kept if they fall on `day` in `zone`. */
@@ -30,8 +32,9 @@ export function settledOn(db: Database, zone: string, day: string): Settled {
3032 if (r.kind === "s") sent++;
3133 else declined++;
3234 }
33 const held = db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM reminder_hold WHERE held_on = ?1").get(day)!.n;
34 return { sent, declined, held };
35 const held = db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM reminder_hold WHERE held_on = ?1 AND back_on IS NULL").get(day)!.n;
36 const dated = db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM reminder_hold WHERE held_on = ?1 AND back_on IS NOT NULL").get(day)!.n;
37 return { sent, declined, held, dated };
3538 }
3639
3740 /** "You approved 3 and declined 1 today. 2 held until tomorrow."; empty when nothing was settled or held today. */
@@ -39,5 +42,6 @@ export function settledLine(s: Settled): string {
3942 const parts = [s.sent > 0 ? `approved ${s.sent}` : "", s.declined > 0 ? `declined ${s.declined}` : ""].filter((p) => p !== "");
4043 const done = parts.length === 0 ? "" : `You ${parts.join(" and ")} today.`;
4144 const held = s.held > 0 ? `${s.held} held until tomorrow.` : "";
42 return [done, held].filter((p) => p !== "").join(" ");
45 const dated = (s.dated ?? 0) > 0 ? `${s.dated} held until a day you chose.` : "";
46 return [done, held, dated].filter((p) => p !== "").join(" ");
4347 }
src/terminal.test.ts +26 −4
@@ -275,7 +275,7 @@ test("h holds a reminder until tomorrow: it leaves the list, is written, and ano
275275 const row = under(t);
276276 const holds = () => db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_hold").get()!.n;
277277 const before = holds();
278 t.press("h");
278 press(t, "h", "\r");
279279 expect(lines(t).join("\n")).toContain("Held until");
280280 expect(holds()).toBe(before + 1);
281281 expect(lines(t).join("\n")).not.toContain(row);
@@ -285,11 +285,33 @@ test("h holds a reminder until tomorrow: it leaves the list, is written, and ano
285285 expect(holds()).toBe(before + 1);
286286 });
287287
288 test("h asks for a bring-back date: a day holds until then, a bad one holds nothing, blank is tomorrow", () => {
289 const t = terminal(db, alice, context);
290 t.press("r");
291 const holds = () => db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_hold").get()!.n;
292 const backs = () => db.query<{ until: string; back_on: string | null }, []>("SELECT until, back_on FROM reminder_hold ORDER BY id DESC LIMIT 1").get()!;
293 const day = today(DEFAULT_ZONE);
294 const before = holds();
295 t.press("h");
296 expect(lines(t).join("\n")).toContain("Bring back on (YYYY-MM-DD, blank for tomorrow)");
297 press(t, "nope", "\r");
298 expect(holds()).toBe(before);
299 press(t, "h", addDays(day, -1), "\r");
300 expect(holds()).toBe(before);
301 press(t, "h", addDays(day, 2), "\r");
302 expect(holds()).toBe(before + 1);
303 expect(backs()).toEqual({ until: addDays(day, 2), back_on: addDays(day, 2) });
304 expect(lines(t).join("\n")).toContain(`Held until ${spoken(addDays(day, 2))}`);
305 press(t, "h", "\r");
306 expect(holds()).toBe(before + 2);
307 expect(backs()).toEqual({ until: addDays(day, 1), back_on: null });
308 });
309
288310 test("H lists the held reminders and B brings one back to today; anon cannot", () => {
289311 const t = terminal(db, alice, context);
290312 t.press("r");
291313 const row = under(t).replace(/^▸\s+\S+(\s\S+)?\s+(?=[A-Z])/, "");
292 t.press("h");
314 press(t, "h", "\r");
293315 expect(lines(t).join("\n")).not.toContain(row);
294316 t.press("H");
295317 expect(lines(t).join("\n")).toContain("Held reminders");
@@ -983,7 +1005,7 @@ test("the deal view says which other closing the same day is less than two hours
9831005 goTo(t, "Closing — 5 Clash Rd");
9841006 t.press("o");
9851007 expect(lines(t).join("\n")).not.toContain("Also closing at");
986 });
1008 }, 20_000);
9871009
9881010 test("the deal view and the week say which other appointment the same day is less than two hours away", () => {
9891011 const make = (address: string, time: string) => {
@@ -1075,4 +1097,4 @@ test("p and e set how a party likes to be reached, an unknown value is refused,
10751097 for (let i = 0; i < 20 && !under(back).includes("Tex Lender"); i++) back.press("j");
10761098 press(back, "e", "Tex Lender\r", "[email protected]\r", "\r", "\r");
10771099 expect(read().parties.find((p) => p.id === tex.id)!.reach).toBe("");
1078 });
1100 }, 20_000);
src/terminal.ts +6 −4
@@ -5,7 +5,7 @@
55 *
66 * dates every open date of every deal, soonest first (h shows done ones)
77 * week the open dates overdue or due in the next seven days, by day
8 * reminders what goes out today; enter reads a draft, e edits its words, s sends it; h holds it until tomorrow, H lists the held ones and B brings one back
8 * reminders what goes out today; enter reads a draft, e edits its words, s sends it; h holds it (it asks for a bring-back date; blank is tomorrow), H lists the held ones and B brings one back
99 * people each person on the deals with every open date they owe and how they like to be reached (P)
1010 * tour the five-minute tour on the sample deals, step by step (G)
1111 * timeline a deal's dates as a client would get them (t on a deal)
@@ -442,8 +442,10 @@ export function terminal(
442442 if (drafted.sent_at !== null) said = "Already sent; it cannot be held.";
443443 else if (drafted.declined !== null) said = "Already declined; u undoes it.";
444444 else {
445 const held = holdReminder(db, me, { deadlineId: draft.deadline_id, partyId: draft.party_id, moment: draft.moment });
446 said = report(held, () => `Held until ${held.ok ? spoken(held.value.until) : ""}: ${draft.party_name}'s reminder returns then.`);
445 ask(deal, ["Bring back on (YYYY-MM-DD, blank for tomorrow)"], ([backOn]) => {
446 const held = holdReminder(db, me, { deadlineId: draft.deadline_id, partyId: draft.party_id, moment: draft.moment, backOn: backOn?.trim() });
447 return report(held, () => `Held until ${held.ok ? spoken(held.value.until) : ""}: ${draft.party_name}'s reminder returns then.`);
448 }, 10);
447449 }
448450 }
449451 if (key === "u") {
@@ -982,7 +984,7 @@ export function terminal(
982984 : view === "reminders"
983985 ? showHeld
984986 ? "j/k move · enter read · B bring back to today · H today's reminders · d dates · q quit"
985 : "j/k move · enter read · e edit · s send · n don't send · h tomorrow · u undo · H held · / find · d dates · q quit"
987 : "j/k move · enter read · e edit · s send · n don't send · h hold · u undo · H held · / find · d dates · q quit"
986988 : view === "messages"
987989 ? "j/k move · enter read · b back to the deal · d dates · q quit"
988990 : view === "timeline" || view === "file"

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs