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
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/292-a-held-reminder-can-come-back-on-a-day-t → main 5 commits fork
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. | ||
| 87 | 87 | timeline and at the foot of every new reminder. (Only an admin can save |
| 88 | 88 | it; **Use defaults** goes back to the sample agent.) |
| 89 | 89 | |
| 90 | ## 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 | ||
| 90 | 98 | ## How people like to be reached |
| 91 | 99 | |
| 92 | 100 | A realtor knows who answers texts and who only reads email. When you add a |
contract/openapi.json +6 −2
| @@ -2096,7 +2096,7 @@ | ||
| 2096 | 2096 | }, |
| 2097 | 2097 | "/api/deals/{id}/reminders/hold": { |
| 2098 | 2098 | "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.", | |
| 2100 | 2100 | "operationId": "postApiDealsIdRemindersHold", |
| 2101 | 2101 | "parameters": [ |
| 2102 | 2102 | { |
| @@ -2140,6 +2140,10 @@ | ||
| 2140 | 2140 | "type": "string", |
| 2141 | 2141 | "minLength": 1, |
| 2142 | 2142 | "maxLength": 2000 |
| 2143 | }, | |
| 2144 | "back_on": { | |
| 2145 | "type": "string", | |
| 2146 | "format": "date" | |
| 2143 | 2147 | } |
| 2144 | 2148 | }, |
| 2145 | 2149 | "required": [ |
| @@ -2153,7 +2157,7 @@ | ||
| 2153 | 2157 | }, |
| 2154 | 2158 | "responses": { |
| 2155 | 2159 | "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.", | |
| 2157 | 2161 | "content": { |
| 2158 | 2162 | "application/json": { |
| 2159 | 2163 | "schema": { |
docs/TECHNICAL.md +1 −1
| @@ -151,7 +151,7 @@ reminder (`deadline_id`, `party_id`, `moment`, `due`, `move_id`) with the mornin | ||
| 151 | 151 | returns (`until`). It leaves `todaysDrafts`, the waiting count and `/tick` until then, |
| 152 | 152 | returns at the top as "Held from <weekday>", and holding again pushes `until` one more |
| 153 | 153 | 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. | |
| 155 | 155 | |
| 156 | 156 | 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. |
| 157 | 157 |
src/db.ts +2
| @@ -574,6 +574,8 @@ export const migrations: string[] = [ | ||
| 574 | 574 | `ALTER TABLE deadline ADD COLUMN place TEXT CHECK (place IS NULL OR length(place) BETWEEN 1 AND 200)`, |
| 575 | 575 | // How a party likes to be reached: '' for no preference, else text, email or call. |
| 576 | 576 | `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`, | |
| 577 | 579 | ]; |
| 578 | 580 | |
| 579 | 581 | export function openDb(dir = dataDir): Database { |
src/deals.ts +11 −6
| @@ -128,6 +128,8 @@ export interface Held { | ||
| 128 | 128 | /** The words held with it; '' while none were. */ |
| 129 | 129 | readonly subject: string; |
| 130 | 130 | 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; | |
| 131 | 133 | } |
| 132 | 134 | export interface Sent { |
| 133 | 135 | readonly id: number; |
| @@ -1430,7 +1432,7 @@ export function declineReminder( | ||
| 1430 | 1432 | export function holdReminder( |
| 1431 | 1433 | db: Database, |
| 1432 | 1434 | 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 }, | |
| 1434 | 1436 | ): Result<Held> { |
| 1435 | 1437 | const { dealId, found } = draftOf(db, who, input); |
| 1436 | 1438 | if (!found.ok) return within(db, who, "reminder.hold", dealId, found); |
| @@ -1450,14 +1452,17 @@ export function holdReminder( | ||
| 1450 | 1452 | const body = input.body === undefined ? undefined : message(input.body, 1, BODY_MAX); |
| 1451 | 1453 | if (input.body !== undefined && body === undefined) |
| 1452 | 1454 | 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); | |
| 1454 | 1459 | 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, | |
| 1458 | 1463 | 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 *`, |
| 1459 | 1464 | ) |
| 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)!; | |
| 1461 | 1466 | return within(db, who, `reminder.hold:${until}`, dealId, ok(held)); |
| 1462 | 1467 | } |
| 1463 | 1468 |
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[] = [ | ||
| 319 | 319 | { |
| 320 | 320 | name: "hold_reminder", |
| 321 | 321 | 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.", | |
| 323 | 323 | inputSchema: { |
| 324 | 324 | 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" } }, | |
| 326 | 326 | required: ["deadline_id", "party_id", "moment"], |
| 327 | 327 | }, |
| 328 | 328 | 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 }), | |
| 330 | 330 | }, |
| 331 | 331 | { |
| 332 | 332 | name: "release_reminder", |
src/migrations.pin.json +2 −1
| @@ -58,5 +58,6 @@ | ||
| 58 | 58 | "429a2adfb36ff61def2d1979556a9db87f4758663fa9c56b97e599b3bc13b773", |
| 59 | 59 | "c5df0d263e04d4e07f9df383beb57f033966af540bb228d9bbbd7b57f3620408", |
| 60 | 60 | "4a52d9de76ede6d34d831d9bc6fc1d3d3a501a5d3652bf63695a9de49f2a7d39", |
| 61 | "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3" | |
| 61 | "fafa7c66cff02dd250f2bed223947161b54f286b868fdd07c07c8c6d8b4556e3", | |
| 62 | "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5" | |
| 62 | 63 | ] |
src/migrations.test.ts +1 −1
| @@ -13,7 +13,7 @@ test("every migration is pinned by position; a pull may only append", () => { | ||
| 13 | 13 | }); |
| 14 | 14 | |
| 15 | 15 | 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)); | |
| 17 | 17 | const db = new Database(":memory:"); |
| 18 | 18 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"); |
| 19 | 19 | 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 | ||
| 135 | 135 | const waiting = holding.length === 0 |
| 136 | 136 | ? "" |
| 137 | 137 | : `<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>`) | |
| 139 | 139 | .join("")}</ul>`; |
| 140 | 140 | if (deal.sent.length === 0) return `${waiting}<p class="muted">Nothing sent yet</p>`; |
| 141 | 141 | const live = new Set(deal.deadlines.map((d) => d.id)); |
src/pages/review.ts +5 −4
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | import type { Agent } from "../reminders.ts"; |
| 10 | 10 | import type { Next } from "../coming.ts"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import { PARTY_LABEL } from "../deadlines.ts"; |
| 14 | 14 | import { asset, escape } from "../http.ts"; |
| 15 | 15 | import { heldFrom, momentLabel, type Draft } from "../reminders.ts"; |
| @@ -80,7 +80,7 @@ export function reviewPage(input: { | ||
| 80 | 80 | const body = |
| 81 | 81 | draft === undefined |
| 82 | 82 | ? `<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); | |
| 84 | 84 | 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 }); |
| 85 | 85 | } |
| 86 | 86 | |
| @@ -106,10 +106,10 @@ function contactField(typed: Typed, problem: string, invalid: string): string { | ||
| 106 | 106 | |
| 107 | 107 | function form( |
| 108 | 108 | 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 }, | |
| 110 | 110 | back: string, |
| 111 | 111 | ): string { |
| 112 | const { error } = s; | |
| 112 | const { error, on } = s; | |
| 113 | 113 | const typed = s.typed ?? { subject: draft.subject, body: draft.body, reason: "" }; |
| 114 | 114 | const keys = { deadline: draft.deadline_id, party: draft.party_id, moment: draft.moment, at: s.at, done: s.done, q: s.q }; |
| 115 | 115 | const problem = (field: ReviewField): string => |
| @@ -133,6 +133,7 @@ function form( | ||
| 133 | 133 | <button class="button" data-key="s" aria-keyshortcuts="S">Send</button> |
| 134 | 134 | ${draft.contact === "" ? '<span class="muted small">No contact on file: Send records the approval and your Mail opens with no recipient.</span>' : ""} |
| 135 | 135 | ${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>`} | |
| 136 | 137 | </form> |
| 137 | 138 | <form class="move" method="post" action="${REVIEW_PATH}/decline">${hidden(keys)} |
| 138 | 139 | ${problem("reason")} |
src/reach.test.ts +2 −2
| @@ -56,8 +56,8 @@ test("someone signed out may not set it", () => { | ||
| 56 | 56 | |
| 57 | 57 | test("the migration adds the column to a database that has parties, losing none", () => { |
| 58 | 58 | 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); | |
| 61 | 61 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"); |
| 62 | 62 | migrations.slice(0, last).forEach((sql, i) => { |
| 63 | 63 | 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 { | ||
| 62 | 62 | /** Why the agent chose not to send it, and who; null while it stands. */ |
| 63 | 63 | readonly declined: { readonly reason: string; readonly by_name: string } | null; |
| 64 | 64 | /** 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; | |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | export const MOMENTS: readonly Moment[] = ["before", "day_of", "late", "moved"]; |
| @@ -255,7 +255,7 @@ export function draftsFor(deal: DealView, deadline: DeadlineView, agent: Agent): | ||
| 255 | 255 | sent_at: sent?.sent_at ?? null, |
| 256 | 256 | sent_on: sent === undefined ? null : dayOfStamp(deal.zone, sent.sent_at), |
| 257 | 257 | 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 }) }, | |
| 259 | 259 | }]; |
| 260 | 260 | }), |
| 261 | 261 | ); |
| @@ -306,7 +306,7 @@ export function checkinDrafts(deal: DealView, agent: Agent): Draft[] { | ||
| 306 | 306 | sent_at: sent?.sent_at ?? null, |
| 307 | 307 | sent_on: sent === undefined ? null : dayOfStamp(deal.zone, sent.sent_at), |
| 308 | 308 | 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 }) }, | |
| 310 | 310 | }; |
| 311 | 311 | }); |
| 312 | 312 | } |
| @@ -356,7 +356,7 @@ export function anniversaryDrafts(deal: DealView, agent: Agent): Draft[] { | ||
| 356 | 356 | sent_at: sent?.sent_at ?? null, |
| 357 | 357 | sent_on: sent === undefined ? null : dayOfStamp(deal.zone, sent.sent_at), |
| 358 | 358 | 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 }) }, | |
| 360 | 360 | }; |
| 361 | 361 | }), |
| 362 | 362 | ); |
| @@ -510,7 +510,7 @@ export const todaysDrafts = (deals: readonly DealView[], day: string, agent: Age | ||
| 510 | 510 | const returned = (d: Draft): boolean => d.held !== null; |
| 511 | 511 | |
| 512 | 512 | /** "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"); | |
| 514 | 514 | |
| 515 | 515 | const waitingIn = memoizer<number>(400); |
| 516 | 516 |
src/routes.ts +7 −6
| @@ -39,7 +39,7 @@ import { | ||
| 39 | 39 | } from "./http.ts"; |
| 40 | 40 | import type { Verb } from "./can.ts"; |
| 41 | 41 | import { events } from "./live.ts"; |
| 42 | import { DEFAULT_ZONE, dayOf } from "./dates.ts"; | |
| 42 | import { DEFAULT_ZONE, dayOf, isDate, spoken } from "./dates.ts"; | |
| 43 | 43 | import { nextComing, type Next } from "./coming.ts"; |
| 44 | 44 | import { tally } from "./book.ts"; |
| 45 | 45 | import { AMOUNT_MAX, RATE_MAX } from "./money.ts"; |
| @@ -647,12 +647,13 @@ const fieldOf = (message: string): ReviewField => (/^The subject/.test(message) | ||
| 647 | 647 | * sheet uses. Done, the page shows the next unsent draft; refused for what was |
| 648 | 648 | * typed, it shows the same draft again with the words kept. |
| 649 | 649 | */ |
| 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> { | |
| 651 | 651 | const f = await form(c); |
| 652 | 652 | if (f instanceof Response) return f; |
| 653 | 653 | const q = query(f.q ?? "") ?? ""; |
| 654 | 654 | const at = count(f.at); |
| 655 | 655 | const settled = count(f.done); |
| 656 | const said = typeof saying === "string" ? saying : saying(f); | |
| 656 | 657 | const result = act(f); |
| 657 | 658 | if (result.ok) return back(said, reviewHref(q, at, settled + 1), said === SENT ? result.value.id : undefined); |
| 658 | 659 | if (result.error === "signin_required" || result.error === "forbidden") |
| @@ -1466,7 +1467,7 @@ export const routes: readonly Route[] = [ | ||
| 1466 | 1467 | { |
| 1467 | 1468 | method: "POST", |
| 1468 | 1469 | 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)), | |
| 1470 | 1471 | }, |
| 1471 | 1472 | { |
| 1472 | 1473 | method: "POST", |
| @@ -1745,14 +1746,14 @@ export const routes: readonly Route[] = [ | ||
| 1745 | 1746 | const body = await readJson(c.request); |
| 1746 | 1747 | if (body === undefined) return json({ error: "too_large" }, 413); |
| 1747 | 1748 | 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); | |
| 1749 | 1750 | }, |
| 1750 | 1751 | api: { |
| 1751 | 1752 | 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.", | |
| 1753 | 1754 | body: { |
| 1754 | 1755 | 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" } }, | |
| 1756 | 1757 | required: ["deadline_id", "party_id", "moment"], |
| 1757 | 1758 | }, |
| 1758 | 1759 | 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 { | ||
| 12 | 12 | readonly declined: number; |
| 13 | 13 | /** Held today and still held: a release deletes the hold. */ |
| 14 | 14 | readonly held: number; |
| 15 | /** Of those held today, the ones the agent gave a day to come back on. */ | |
| 16 | readonly dated?: number; | |
| 15 | 17 | } |
| 16 | 18 | |
| 17 | 19 | /** 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 { | ||
| 30 | 32 | if (r.kind === "s") sent++; |
| 31 | 33 | else declined++; |
| 32 | 34 | } |
| 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 }; | |
| 35 | 38 | } |
| 36 | 39 | |
| 37 | 40 | /** "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 { | ||
| 39 | 42 | const parts = [s.sent > 0 ? `approved ${s.sent}` : "", s.declined > 0 ? `declined ${s.declined}` : ""].filter((p) => p !== ""); |
| 40 | 43 | const done = parts.length === 0 ? "" : `You ${parts.join(" and ")} today.`; |
| 41 | 44 | 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(" "); | |
| 43 | 47 | } |
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 | ||
| 275 | 275 | const row = under(t); |
| 276 | 276 | const holds = () => db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_hold").get()!.n; |
| 277 | 277 | const before = holds(); |
| 278 | t.press("h"); | |
| 278 | press(t, "h", "\r"); | |
| 279 | 279 | expect(lines(t).join("\n")).toContain("Held until"); |
| 280 | 280 | expect(holds()).toBe(before + 1); |
| 281 | 281 | 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 | ||
| 285 | 285 | expect(holds()).toBe(before + 1); |
| 286 | 286 | }); |
| 287 | 287 | |
| 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 | ||
| 288 | 310 | test("H lists the held reminders and B brings one back to today; anon cannot", () => { |
| 289 | 311 | const t = terminal(db, alice, context); |
| 290 | 312 | t.press("r"); |
| 291 | 313 | const row = under(t).replace(/^▸\s+\S+(\s\S+)?\s+(?=[A-Z])/, ""); |
| 292 | t.press("h"); | |
| 314 | press(t, "h", "\r"); | |
| 293 | 315 | expect(lines(t).join("\n")).not.toContain(row); |
| 294 | 316 | t.press("H"); |
| 295 | 317 | 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 | ||
| 983 | 1005 | goTo(t, "Closing — 5 Clash Rd"); |
| 984 | 1006 | t.press("o"); |
| 985 | 1007 | expect(lines(t).join("\n")).not.toContain("Also closing at"); |
| 986 | }); | |
| 1008 | }, 20_000); | |
| 987 | 1009 | |
| 988 | 1010 | test("the deal view and the week say which other appointment the same day is less than two hours away", () => { |
| 989 | 1011 | 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, | ||
| 1075 | 1097 | for (let i = 0; i < 20 && !under(back).includes("Tex Lender"); i++) back.press("j"); |
| 1076 | 1098 | press(back, "e", "Tex Lender\r", "[email protected]\r", "\r", "\r"); |
| 1077 | 1099 | expect(read().parties.find((p) => p.id === tex.id)!.reach).toBe(""); |
| 1078 | }); | |
| 1100 | }, 20_000); |
src/terminal.ts +6 −4
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * |
| 6 | 6 | * dates every open date of every deal, soonest first (h shows done ones) |
| 7 | 7 | * 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 | |
| 9 | 9 | * people each person on the deals with every open date they owe and how they like to be reached (P) |
| 10 | 10 | * tour the five-minute tour on the sample deals, step by step (G) |
| 11 | 11 | * timeline a deal's dates as a client would get them (t on a deal) |
| @@ -442,8 +442,10 @@ export function terminal( | ||
| 442 | 442 | if (drafted.sent_at !== null) said = "Already sent; it cannot be held."; |
| 443 | 443 | else if (drafted.declined !== null) said = "Already declined; u undoes it."; |
| 444 | 444 | 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); | |
| 447 | 449 | } |
| 448 | 450 | } |
| 449 | 451 | if (key === "u") { |
| @@ -982,7 +984,7 @@ export function terminal( | ||
| 982 | 984 | : view === "reminders" |
| 983 | 985 | ? showHeld |
| 984 | 986 | ? "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" | |
| 986 | 988 | : view === "messages" |
| 987 | 989 | ? "j/k move · enter read · b back to the deal · d dates · q quit" |
| 988 | 990 | : view === "timeline" || view === "file" |
new above
offline