Public: anyone can read this; the crew can change it.
The agent chooses a warm or a brief voice for reminder drafts on the Profile page #301
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/297-the-agent-chooses-a-warm-or-a-brief-voic → main 5 commits fork 1 thread · 1 unresolved
19 files
changed +402
−69
README.md +6 −1
| @@ -232,7 +232,12 @@ The app ships with a sample agent; the Profile in step 10 replaces it. Each | ||
| 232 | 232 | deal sets how many days before a date its reminder goes (1 to 30), and your |
| 233 | 233 | Profile may add one line of your own to the end of every message ("Text me |
| 234 | 234 | anytime, I answer within the hour"). Set your time zone on the Profile so |
| 235 | "today" is your day, not the server's. Whoever runs the app can set your usual | |
| 235 | "today" is your day, not the server's. Choose a voice there too: **Warm** | |
| 236 | (the friendly wording, the default) or **Brief** (the same facts in shorter | |
| 237 | sentences, no pleasantry paragraph); the page shows a sample of each, and the | |
| 238 | terminal's profile (`I`, then `e`) and the `set_profile` tool set it too, for an | |
| 239 | admin only. New drafts use it; a message already sent, or held, keeps the words | |
| 240 | it had when you sent or held it. Whoever runs the app can set your usual | |
| 236 | 241 | contract lengths once; see the technical page. On your phone, choose Add to |
| 237 | 242 | Home Screen: the deal-clock mark sits beside the name and it opens full screen. |
| 238 | 243 |
contract/openapi.json +32
| @@ -46,6 +46,14 @@ | ||
| 46 | 46 | "maxLength": 200, |
| 47 | 47 | "description": "One line of the agent's own, no line breaks or control characters, above the sign-off of every new draft; empty for none. On a save, leaving it out keeps the saved line; only an empty string clears it." |
| 48 | 48 | }, |
| 49 | "voice": { | |
| 50 | "type": "string", | |
| 51 | "enum": [ | |
| 52 | "warm", | |
| 53 | "brief" | |
| 54 | ], | |
| 55 | "description": "How new drafts sound: warm (the default) or brief. Leaving it out on a save keeps the saved voice; any other value is a 422 naming voice. Drafts already sent or held keep their words." | |
| 56 | }, | |
| 49 | 57 | "saved": { |
| 50 | 58 | "type": "boolean", |
| 51 | 59 | "description": "False while the environment's defaults stand." |
| @@ -116,6 +124,14 @@ | ||
| 116 | 124 | "maxLength": 200, |
| 117 | 125 | "description": "One line of the agent's own, no line breaks or control characters, above the sign-off of every new draft; empty for none. On a save, leaving it out keeps the saved line; only an empty string clears it." |
| 118 | 126 | }, |
| 127 | "voice": { | |
| 128 | "type": "string", | |
| 129 | "enum": [ | |
| 130 | "warm", | |
| 131 | "brief" | |
| 132 | ], | |
| 133 | "description": "How new drafts sound: warm (the default) or brief. Leaving it out on a save keeps the saved voice; any other value is a 422 naming voice. Drafts already sent or held keep their words." | |
| 134 | }, | |
| 119 | 135 | "saved": { |
| 120 | 136 | "type": "boolean", |
| 121 | 137 | "description": "False while the environment's defaults stand." |
| @@ -167,6 +183,14 @@ | ||
| 167 | 183 | "maxLength": 200, |
| 168 | 184 | "description": "One line of the agent's own, no line breaks or control characters, above the sign-off of every new draft; empty for none. On a save, leaving it out keeps the saved line; only an empty string clears it." |
| 169 | 185 | }, |
| 186 | "voice": { | |
| 187 | "type": "string", | |
| 188 | "enum": [ | |
| 189 | "warm", | |
| 190 | "brief" | |
| 191 | ], | |
| 192 | "description": "How new drafts sound: warm (the default) or brief. Leaving it out on a save keeps the saved voice; any other value is a 422 naming voice. Drafts already sent or held keep their words." | |
| 193 | }, | |
| 170 | 194 | "saved": { |
| 171 | 195 | "type": "boolean", |
| 172 | 196 | "description": "False while the environment's defaults stand." |
| @@ -238,6 +262,14 @@ | ||
| 238 | 262 | "maxLength": 200, |
| 239 | 263 | "description": "One line of the agent's own, no line breaks or control characters, above the sign-off of every new draft; empty for none. On a save, leaving it out keeps the saved line; only an empty string clears it." |
| 240 | 264 | }, |
| 265 | "voice": { | |
| 266 | "type": "string", | |
| 267 | "enum": [ | |
| 268 | "warm", | |
| 269 | "brief" | |
| 270 | ], | |
| 271 | "description": "How new drafts sound: warm (the default) or brief. Leaving it out on a save keeps the saved voice; any other value is a 422 naming voice. Drafts already sent or held keep their words." | |
| 272 | }, | |
| 241 | 273 | "saved": { |
| 242 | 274 | "type": "boolean", |
| 243 | 275 | "description": "False while the environment's defaults stand." |
docs/TECHNICAL.md +1 −1
| @@ -281,7 +281,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 281 | 281 | | `src/guidance.ts` | the one plain line telling a client what to do for an open date: kind and side in, a string out; called by the client timeline and its page | |
| 282 | 282 | | `src/memo.ts` | work done once per deal content: `print` keys a deal by its rows, `memoizer(limit)` is a bounded cache; called by `src/http.ts`, `src/reminders.ts` and the clock and sheet pages | |
| 283 | 283 | | `src/money.ts` | the sale price and earnest amount in whole dollars: `amount` validates input, `dollars` formats it one way; called by deals, book, reminders, MCP, routes and the pages | |
| 284 | | `src/profile.ts` | the agent's saved details (one `profile` row, the environment as defaults): `agentOf`, `zoneOf`, `getProfile`, `setProfile`, `useDefaults`, with `permit` on the writes; called by deals, MCP, routes, sample, the terminal and the Profile page | | |
| 284 | | `src/profile.ts` | the agent's saved details (one `profile` row, the environment as defaults): `agentOf`, `zoneOf`, `getProfile`, `setProfile`, `useDefaults`, with `permit` on the writes (the `voice`, warm or brief, is one of its fields and the drafting in `src/reminders.ts` reads it and the two voices' words live in `src/wording.ts`; `src/voice.ts` renders the Profile page's sample of each); called by deals, MCP, routes, sample, the terminal and the Profile page | | |
| 285 | 285 | | `src/progress.ts` | how far along a deal is: a deal and a day in, dates done of total, overdue count and days to closing out; called by routes and the clock, deal and progress pages | |
| 286 | 286 | | `src/settled.ts` | what the agent settled today, counted from the records: a database, zone and day in, sent, declined and held counts out; called by routes and the review page | |
| 287 | 287 | | `src/shift.ts` | "Closing moved": a deal and a new closing date in, the open non-custom dates that would shift by the same days out, rolled to business days; confirming goes through `amendDeal` | |
src/checkin.ts −16
| @@ -19,14 +19,6 @@ export function checkinRoles(side: Side): readonly PartyRole[] { | ||
| 19 | 19 | return side === "buyer" ? ["buyer"] : side === "listing" ? ["seller"] : ["buyer", "seller"]; |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | /** The words of a check-in to one client; never the price. `signOff` is the agent's usual ending. */ | |
| 23 | export function checkinWords(address: string, firstName: string, signOff: string): { subject: string; body: string } { | |
| 24 | return { | |
| 25 | subject: `How is the new home? — ${address}`, | |
| 26 | body: `Hi ${firstName}, it has been about a month since ${address}, and I wanted to ask how the move went. Is the home treating you well?\n\nIf anything has come up since closing, I'm happy to help with it, whether that's a question about the paperwork or a good name for a plumber.\n\nAnd if you know someone thinking of buying or selling, I'd be glad to look after them the way I looked after you. No pressure at all; I'm just glad to have been part of it.\n\n${signOff}`, | |
| 27 | }; | |
| 28 | } | |
| 29 | ||
| 30 | 22 | // ---- the home anniversary: each year after the closing, to the same clients ---- |
| 31 | 23 | |
| 32 | 24 | /** How many anniversaries are drafted after a closing: ten years of keeping in touch. */ |
| @@ -43,11 +35,3 @@ export function anniversaryOn(doneOn: string, year: number): string { | ||
| 43 | 35 | const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; |
| 44 | 36 | return `${String(y).padStart(4, "0")}-${md === "02-29" && !leap ? "02-28" : md}`; |
| 45 | 37 | } |
| 46 | ||
| 47 | /** The words of a home anniversary note to one client; never the price. */ | |
| 48 | export function anniversaryWords(address: string, firstName: string, signOff: string): { subject: string; body: string } { | |
| 49 | return { | |
| 50 | subject: `Happy home anniversary — ${address}`, | |
| 51 | body: `Hi ${firstName}, happy home anniversary! It was a year ago that ${address} became yours, and I was thinking of you today. I hope it feels more like home with every season.\n\nIf you know someone thinking of buying or selling, I'd be glad to look after them the way I looked after you. No pressure at all; I'm just glad to have been part of it.\n\n${signOff}`, | |
| 52 | }; | |
| 53 | } |
src/db.ts +2
| @@ -588,6 +588,8 @@ export const migrations: string[] = [ | ||
| 588 | 588 | `ALTER TABLE deadline_move ADD COLUMN undo INTEGER NOT NULL DEFAULT 0`, |
| 589 | 589 | // The agent's private note on a deal: for their eyes only, never in a draft, timeline or handout. |
| 590 | 590 | `ALTER TABLE deal ADD COLUMN note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 500)`, |
| 591 | // How new drafts sound: warm (the original wording) or brief (src/reminders.ts). | |
| 592 | `ALTER TABLE profile ADD COLUMN voice TEXT NOT NULL DEFAULT 'warm' CHECK (voice IN ('warm', 'brief'))`, | |
| 591 | 593 | ]; |
| 592 | 594 | |
| 593 | 595 | export function openDb(dir = dataDir): Database { |
src/deals.ts +1 −1
| @@ -1541,7 +1541,7 @@ export function holdReminder( | ||
| 1541 | 1541 | 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, |
| 1542 | 1542 | 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 *`, |
| 1543 | 1543 | ) |
| 1544 | .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)!; | |
| 1544 | .get(deal.id, draft.deadline_id, draft.party_id, draft.moment, draft.due, draft.move_id, until, day, authorOf(who).name, subject ?? draft.subject, body ?? draft.body, asked ?? null)!; | |
| 1545 | 1545 | return within(db, who, `reminder.hold:${until}`, dealId, ok(held)); |
| 1546 | 1546 | } |
| 1547 | 1547 |
src/leaks.test.ts +18
| @@ -66,3 +66,21 @@ test("a party with no contact and a date with no time or place leaves no leak", | ||
| 66 | 66 | expect(all.flatMap((d) => leaks(d, agent))).toEqual([]); |
| 67 | 67 | } |
| 68 | 68 | }); |
| 69 | ||
| 70 | const brief = agents.map((a) => ({ ...a, voice: "brief" as const })); | |
| 71 | const warm = agents.map((a) => ({ ...a, voice: "warm" as const })); | |
| 72 | ||
| 73 | test("both voices of every sample draft pass the leak test, and brief is shorter than warm", () => { | |
| 74 | const db = openDb(mkdtempSync(join(tmpdir(), "app-leaks-voice-"))); | |
| 75 | seed(db); | |
| 76 | agents.forEach((_, i) => { | |
| 77 | const w = everyDraft(db, warm[i]!); | |
| 78 | const b = everyDraft(db, brief[i]!); | |
| 79 | expect(b.length).toBe(w.length); | |
| 80 | expect(b.flatMap((d) => leaks(d, brief[i]!))).toEqual([]); | |
| 81 | b.forEach((d, j) => { | |
| 82 | expect(d.body.length, `${d.moment} ${d.label} ${d.party_name}`).toBeLessThan(w[j]!.body.length); | |
| 83 | expect(d.subject).toBe(w[j]!.subject); | |
| 84 | }); | |
| 85 | }); | |
| 86 | }); |
src/mcp.ts +1
| @@ -560,6 +560,7 @@ export const tools: readonly Tool[] = [ | ||
| 560 | 560 | phone: { type: "string", maxLength: 30 }, |
| 561 | 561 | email: { type: "string", maxLength: 254 }, |
| 562 | 562 | line: { type: "string", maxLength: 200, description: "One line of your own above the sign-off; no line breaks. Leave it out to keep the saved line; an empty string clears it." }, |
| 563 | voice: { type: "string", enum: ["warm", "brief"], description: "How new drafts sound: warm (the default) or brief. Leave it out to keep the saved voice." }, | |
| 563 | 564 | }, |
| 564 | 565 | required: ["name", "brokerage", "phone", "email"], |
| 565 | 566 | }, |
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|reminder_hold ADD COLUMN back_on|TABLE IF NOT EXISTS paper|deal ADD COLUMN note|deadline_move ADD COLUMN undo/.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|TABLE IF NOT EXISTS paper|deal ADD COLUMN note|deadline_move ADD COLUMN undo|profile ADD COLUMN voice/.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/profile.ts +11 −1
| @@ -8,6 +8,7 @@ import { DEFAULT_ZONE, US_ZONES } from "../dates.ts"; | ||
| 8 | 8 | import { escape } from "../http.ts"; |
| 9 | 9 | import type { Profile } from "../profile.ts"; |
| 10 | 10 | import type { Agent } from "../reminders.ts"; |
| 11 | import { VOICES, type Voice } from "../wording.ts"; | |
| 11 | 12 | import type { Who } from "../who.ts"; |
| 12 | 13 | import { layout } from "./layout.ts"; |
| 13 | 14 | |
| @@ -26,9 +27,11 @@ export function profilePage(input: { | ||
| 26 | 27 | /** Whether the table lets `who` save it. */ |
| 27 | 28 | mayChange: boolean; |
| 28 | 29 | notice?: string | undefined; |
| 30 | /** The first sample deal's first reminder in each voice, for the choice's sample; none without a sample deal. */ | |
| 31 | samples?: Readonly<Record<Voice, string>> | undefined; | |
| 29 | 32 | state?: ProfileState | undefined; |
| 30 | 33 | }): string { |
| 31 | const { who, profile, mayChange, notice, state } = input; | |
| 34 | const { who, profile, mayChange, notice, state, samples } = input; | |
| 32 | 35 | const agent: Agent = profile; |
| 33 | 36 | const values = state?.values ?? {}; |
| 34 | 37 | const problem = state?.problem; |
| @@ -43,6 +46,10 @@ export function profilePage(input: { | ||
| 43 | 46 | const zone = values.zone ?? agent.zone ?? DEFAULT_ZONE; |
| 44 | 47 | const zones = US_ZONES.some(([name]) => name === zone) ? US_ZONES : [...US_ZONES, [zone, zone] as const]; |
| 45 | 48 | const zoneName = zones.find(([name]) => name === zone)?.[1] ?? zone; |
| 49 | const voice = values.voice ?? agent.voice ?? "warm"; | |
| 50 | const voiceName: Readonly<Record<Voice, string>> = { warm: "Warm", brief: "Brief" }; | |
| 51 | const sample = (v: Voice): string => | |
| 52 | samples === undefined ? "" : `<blockquote class="muted small"><strong>${voiceName[v]}</strong><br>${escape(samples[v]).replaceAll("\n", "<br>")}</blockquote>`; | |
| 46 | 53 | const general = |
| 47 | 54 | problem !== undefined && problem.field === undefined |
| 48 | 55 | ? `<p class="field-error" role="alert">${escape(problem.message)}</p>` |
| @@ -55,6 +62,7 @@ export function profilePage(input: { | ||
| 55 | 62 | <label>Email<input class="input" type="email" name="email" required maxlength="254" autocomplete="email" value="${val("email")}"${bad("email")}>${why("email")}</label> |
| 56 | 63 | <label>A line of your own at the end of every message<input class="input" name="line" maxlength="200" value="${val("line")}"${bad("line")}>${why("line")}</label> |
| 57 | 64 | <label>Your time zone<select class="input" name="zone"${bad("zone")}>${zones.map(([name, label]) => `<option value="${escape(name)}"${name === zone ? " selected" : ""}>${escape(label)}</option>`).join("")}</select>${why("zone")}</label> |
| 65 | <fieldset class="stack"><legend>Voice of new drafts</legend>${VOICES.map((v) => `<label><input type="radio" name="voice" value="${v}"${v === voice ? " checked" : ""}> ${voiceName[v]}${v === "warm" ? " (friendly, the default)" : " (same facts, fewer words)"}</label>`).join("")}${why("voice")}</fieldset> | |
| 58 | 66 | <div class="actions"> |
| 59 | 67 | <button class="button">Save profile</button> |
| 60 | 68 | <button class="button button-quiet" formaction="/profile/defaults" formnovalidate>Use defaults</button> |
| @@ -66,6 +74,7 @@ export function profilePage(input: { | ||
| 66 | 74 | <div><dt class="muted small">Phone</dt><dd>${escape(agent.phone)}</dd></div> |
| 67 | 75 | <div><dt class="muted small">Email</dt><dd>${escape(agent.email)}</dd></div> |
| 68 | 76 | <div><dt class="muted small">Your time zone</dt><dd>${escape(zoneName)}</dd></div> |
| 77 | <div><dt class="muted small">Voice of new drafts</dt><dd>${voiceName[agent.voice ?? "warm"]}</dd></div> | |
| 69 | 78 | <div><dt class="muted small">A line of your own</dt><dd>${escape(agent.line ?? "") || "None"}</dd></div> |
| 70 | 79 | </dl> |
| 71 | 80 | <p class="muted small">Only an admin can change these.</p>`; |
| @@ -80,6 +89,7 @@ export function profilePage(input: { | ||
| 80 | 89 | <h1>Profile</h1> |
| 81 | 90 | <p class="muted">${profile.saved ? "Saved by you." : "These are the defaults the app was started with."} The header, the client timeline and the sign-off of every unsent reminder carry them. Reminders already sent keep the words that went out. Your time zone decides which day is today.</p> |
| 82 | 91 | ${mayChange ? form : readOnly} |
| 92 | ${samples === undefined ? "" : `<h2>How each voice reads</h2>${sample("warm")}${sample("brief")}`} | |
| 83 | 93 | <p class="small"><a href="/deals/export.json" download>Download all my deals</a> <span class="muted">One file with every deal you can read: dates, parties, notes, moves and the words of every message sent.</span></p> |
| 84 | 94 | ${bringBack} |
| 85 | 95 | </section>`; |
src/profile.ts +13 −7
| @@ -10,6 +10,7 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { DEFAULT_ZONE, isZone } from "./dates.ts"; |
| 11 | 11 | import { agent as defaults, validEmail, validPhone } from "./env.ts"; |
| 12 | 12 | import type { Agent } from "./reminders.ts"; |
| 13 | import { isVoice, VOICES, type Voice } from "./wording.ts"; | |
| 13 | 14 | import { audited, fail, ok, refuse, type Result } from "./result.ts"; |
| 14 | 15 | import { permit } from "./contract.ts"; |
| 15 | 16 | import type { Who } from "./who.ts"; |
| @@ -25,11 +26,12 @@ const FIELDS = ["name", "brokerage", "phone", "email"] as const; | ||
| 25 | 26 | /** The agent the page, the drafts and the files name: the saved row, else the defaults. */ |
| 26 | 27 | export function agentOf(db: Database): Agent { |
| 27 | 28 | const row = db |
| 28 | .query<Agent, []>("SELECT name, brokerage, phone, email, line, zone FROM profile WHERE id = 1") | |
| 29 | .query<Agent, []>("SELECT name, brokerage, phone, email, line, zone, voice FROM profile WHERE id = 1") | |
| 29 | 30 | .get(); |
| 30 | 31 | if (row === null) return defaults; |
| 31 | const { line, ...rest } = row; | |
| 32 | return line ? row : rest; | |
| 32 | // The default voice, like an empty line, is absence: only a chosen "brief" is carried. | |
| 33 | const { line, voice, ...rest } = row; | |
| 34 | return { ...rest, ...(line ? { line } : {}), ...(voice === "brief" ? { voice } : {}) }; | |
| 33 | 35 | } |
| 34 | 36 | |
| 35 | 37 | /** The zone the agent's day is told in: the saved one, else the default. Read once per request or per list. */ |
| @@ -62,7 +64,10 @@ export function checkProfile(input: Record<string, unknown>): Result<Agent> { | ||
| 62 | 64 | // A client that predates the zone does not send it: the zone stands as it is (`setProfile`). |
| 63 | 65 | const zone = typeof input.zone === "string" ? input.zone.trim() : DEFAULT_ZONE; |
| 64 | 66 | if (!isZone(zone)) return fail("invalid", "Your time zone is a name such as America/New_York, one this server knows.", "zone"); |
| 65 | return ok(line ? { name, brokerage, phone, email, line, zone } : { name, brokerage, phone, email, zone }); | |
| 67 | // A client that predates the voice does not send it: the voice stands as it is (`setProfile`). | |
| 68 | const voice = typeof input.voice === "string" ? input.voice.trim() : "warm"; | |
| 69 | if (!isVoice(voice)) return fail("invalid", `Your voice is one of: ${VOICES.join(", ")}.`, "voice"); | |
| 70 | return ok({ name, brokerage, phone, email, ...(line ? { line } : {}), zone, ...(voice === "brief" ? { voice } : {}) }); | |
| 66 | 71 | } |
| 67 | 72 | |
| 68 | 73 | const may = (who: Who): Result<null> => |
| @@ -91,15 +96,16 @@ export function setProfile(db: Database, who: Who, input: Record<string, unknown | ||
| 91 | 96 | ...input, |
| 92 | 97 | ...(typeof input.line === "string" ? {} : { line: now.line ?? "" }), |
| 93 | 98 | ...(typeof input.zone === "string" ? {} : { zone: now.zone ?? DEFAULT_ZONE }), |
| 99 | ...(typeof input.voice === "string" ? {} : { voice: now.voice ?? "warm" }), | |
| 94 | 100 | }; |
| 95 | 101 | const checked = checkProfile(given); |
| 96 | 102 | if (!checked.ok) out = checked; |
| 97 | 103 | else { |
| 98 | 104 | const a = checked.value; |
| 99 | 105 | db.query( |
| 100 | `INSERT INTO profile (id, name, brokerage, phone, email, line, zone) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6) | |
| 101 | ON CONFLICT (id) DO UPDATE SET name = excluded.name, brokerage = excluded.brokerage, phone = excluded.phone, email = excluded.email, line = excluded.line, zone = excluded.zone, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, | |
| 102 | ).run(a.name, a.brokerage, a.phone, a.email, a.line ?? "", a.zone ?? DEFAULT_ZONE); | |
| 106 | `INSERT INTO profile (id, name, brokerage, phone, email, line, zone, voice) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7) | |
| 107 | ON CONFLICT (id) DO UPDATE SET name = excluded.name, brokerage = excluded.brokerage, phone = excluded.phone, email = excluded.email, line = excluded.line, zone = excluded.zone, voice = excluded.voice, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, | |
| 108 | ).run(a.name, a.brokerage, a.phone, a.email, a.line ?? "", a.zone ?? DEFAULT_ZONE, a.voice ?? "warm"); | |
| 103 | 109 | zones.delete(db); |
| 104 | 110 | out = ok({ ...a, id: 1, saved: true }); |
| 105 | 111 | } |
src/reminders.ts +44 −31
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | import { addDays, daysBetween, dayOf, dayOfStamp, relative, short, spoken, today, weekday } from "./dates.ts"; |
| 12 | 12 | import { appointmentAfterDate } from "./appointment.ts"; |
| 13 | 13 | import { closingAfterDate } from "./closing.ts"; |
| 14 | import { ANNIVERSARY_YEARS, anniversaryOn, anniversaryWords, checkinOn, checkinRoles, checkinWords } from "./checkin.ts"; | |
| 14 | import { ANNIVERSARY_YEARS, anniversaryOn, checkinOn, checkinRoles } from "./checkin.ts"; | |
| 15 | import { say, type Voice } from "./wording.ts"; | |
| 15 | 16 | import { dollars } from "./money.ts"; |
| 16 | 17 | import { memoizer, print } from "./memo.ts"; |
| 17 | 18 | import { DEFAULT_LEAD, isInternal, isOpen, KIND, notifiedOf, PARTY_LABEL, type PartyRole } from "./deadlines.ts"; |
| @@ -26,6 +27,8 @@ export interface Agent { | ||
| 26 | 27 | readonly line?: string | undefined; |
| 27 | 28 | /** The IANA zone the agent's day is told in; absent reads as `DEFAULT_ZONE`. */ |
| 28 | 29 | readonly zone?: string | undefined; |
| 30 | /** How new drafts sound; absent reads as warm. Never changes a fact a draft states. */ | |
| 31 | readonly voice?: Voice | undefined; | |
| 29 | 32 | } |
| 30 | 33 | |
| 31 | 34 | /** How every draft ends: the agent's own line if set, then who to call or write back. */ |
| @@ -155,6 +158,7 @@ function words( | ||
| 155 | 158 | agent: Agent, |
| 156 | 159 | notice: Notice | undefined, |
| 157 | 160 | ): { subject: string; body: string } { |
| 161 | const common = { first: first(party.name), address: deal.address, signOff: signOff(agent) }; | |
| 158 | 162 | const owner = deal.parties.find((p) => p.role === deadline.owes); |
| 159 | 163 | const owed = deadline.what === "" ? "" : `${deadline.what}. `; |
| 160 | 164 | const sum = deadline.kind === "earnest" && deal.earnest_amount !== null ? deal.earnest_amount : null; |
| @@ -163,39 +167,39 @@ function words( | ||
| 163 | 167 | sum === null |
| 164 | 168 | ? "" |
| 165 | 169 | : `${party.role === "buyer" ? "Your" : "The buyer's"} ${dollars(sum)} earnest money deposit ${late ? "is still outstanding" : `is due ${short(deadline.due)}`}. `; |
| 170 | const after = (d: DeadlineView): string => (d.kind === "closing" ? closingAfterDate(deal) : appointmentAfterDate(d)); | |
| 171 | const label = deadline.label.toLowerCase(); | |
| 172 | const voice = agent.voice; | |
| 166 | 173 | if (moment === "moved" && notice !== undefined && notice.items.length > 1) { |
| 167 | 174 | const changes = notice.items |
| 168 | .map(({ deadline: d, move }) => `• ${d.label}: from ${spoken(move.from_due)} to ${spoken(move.to_due)}${d.kind === "closing" ? closingAfterDate(deal) : appointmentAfterDate(d)}`) | |
| 175 | .map(({ deadline: d, move }) => `• ${d.label}: from ${spoken(move.from_due)} to ${spoken(move.to_due)}${after(d)}`) | |
| 169 | 176 | .join("\n"); |
| 170 | return { | |
| 171 | subject: `${deal.address} — dates changed`, | |
| 172 | body: `Hi ${first(party.name)}, the contract for ${deal.address} has been amended, and these dates have changed:\n\n${changes}\n\nPlease work to the new dates, and let me know if any of them causes a problem.\n\n${signOff(agent)}`, | |
| 173 | }; | |
| 177 | return say({ ...common, kind: "changes", subject: `${deal.address} — dates changed`, changes }, voice); | |
| 174 | 178 | } |
| 175 | 179 | if (moment === "moved") { |
| 176 | 180 | const move = lastMove(deadline)!; |
| 177 | return { | |
| 178 | subject: `${deadline.label} — ${deal.address} — date changed`, | |
| 179 | body: `Hi ${first(party.name)}, a change of date: the ${deadline.label.toLowerCase()} for ${deal.address} has moved from ${spoken(move.from_due)} to ${spoken(move.to_due)}${deadline.kind === "closing" ? closingAfterDate(deal) : appointmentAfterDate(deadline)}. ${owed}Please work to the new date, and let me know if it causes a problem.\n\n${signOff(agent)}`, | |
| 180 | }; | |
| 181 | return say({ ...common, kind: "moved", subject: `${deadline.label} — ${deal.address} — date changed`, label, from: spoken(move.from_due), to: spoken(move.to_due), after: after(deadline), owed }, voice); | |
| 181 | 182 | } |
| 182 | if (moment === "late") { | |
| 183 | return { | |
| 184 | subject: `${deadline.label} — ${deal.address} — overdue`, | |
| 185 | body: `Hi ${first(party.name)}, the ${deadline.label.toLowerCase()} for ${deal.address} was due ${spoken(deadline.due)} and has passed without being marked done. ${deposit(true)}${owed}Could you send me an update today on where it stands and when it will be done?\n\n${signOff(agent)}`, | |
| 186 | }; | |
| 187 | } | |
| 188 | const when = `${moment === "before" ? `in ${days(deadline.lead_days)}, on ${spoken(deadline.due)}` : `today, ${spoken(deadline.due)}`}${deadline.kind === "closing" ? closingAfterDate(deal) : appointmentAfterDate(deadline)}`; | |
| 183 | if (moment === "late") | |
| 184 | return say({ ...common, kind: "late", subject: `${deadline.label} — ${deal.address} — overdue`, label, due: spoken(deadline.due), deposit: deposit(true), owed }, voice); | |
| 185 | const when = `${moment === "before" ? `in ${days(deadline.lead_days)}, on ${spoken(deadline.due)}` : `today, ${spoken(deadline.due)}`}${after(deadline)}`; | |
| 189 | 186 | // The walk-through is the day before the keys change hands: it says where and when that is. |
| 190 | 187 | const closes = deadline.kind === "walkthrough" && closingAfterDate(deal) !== "" ? `Closing follows on ${spoken(deal.closing)}${closingAfterDate(deal)}. ` : ""; |
| 191 | const yours = party.role === deadline.owes; | |
| 192 | const ask = yours | |
| 193 | ? "This one is yours — please let me know right away if anything could hold it up." | |
| 194 | : `${owner === undefined ? PARTY_LABEL[deadline.owes] : owner.name} (${PARTY_LABEL[deadline.owes].toLowerCase()}) is on it; nothing is needed from you unless you hear otherwise.`; | |
| 195 | return { | |
| 196 | subject: `${deadline.label} — ${deal.address} — ${moment === "before" ? spoken(deadline.due) : "today"}`, | |
| 197 | body: `Hi ${first(party.name)}, a reminder that the ${deadline.label.toLowerCase()} for ${deal.address} is ${when}. ${deposit(false)}${owed}${closes}${ask}\n\n${signOff(agent)}`, | |
| 198 | }; | |
| 188 | return say( | |
| 189 | { | |
| 190 | ...common, | |
| 191 | kind: "due", | |
| 192 | subject: `${deadline.label} — ${deal.address} — ${moment === "before" ? spoken(deadline.due) : "today"}`, | |
| 193 | label, | |
| 194 | when, | |
| 195 | deposit: deposit(false), | |
| 196 | owed, | |
| 197 | closes, | |
| 198 | yours: party.role === deadline.owes, | |
| 199 | owner: `${owner === undefined ? PARTY_LABEL[deadline.owes] : owner.name} (${PARTY_LABEL[deadline.owes].toLowerCase()})`, | |
| 200 | }, | |
| 201 | voice, | |
| 202 | ); | |
| 199 | 203 | } |
| 200 | 204 | |
| 201 | 205 | /** |
| @@ -283,7 +287,7 @@ export function checkinDrafts(deal: DealView, agent: Agent): Draft[] { | ||
| 283 | 287 | const sent = deal.sent.find(mine); |
| 284 | 288 | const declined = deal.declined.find(mine); |
| 285 | 289 | const held = sent === undefined && declined === undefined ? deal.held.find(mine) : undefined; |
| 286 | const drafted = checkinWords(deal.address, first(party.name), signOff(agent)); | |
| 290 | const drafted = say({ kind: "checkin", subject: `How is the new home? — ${deal.address}`, first: first(party.name), address: deal.address, signOff: signOff(agent) }, agent.voice); | |
| 287 | 291 | return { |
| 288 | 292 | deal_id: deal.id, |
| 289 | 293 | address: deal.address, |
| @@ -333,7 +337,7 @@ export function anniversaryDrafts(deal: DealView, agent: Agent): Draft[] { | ||
| 333 | 337 | const sent = deal.sent.find(mine); |
| 334 | 338 | const declined = deal.declined.find(mine); |
| 335 | 339 | const held = sent === undefined && declined === undefined ? deal.held.find(mine) : undefined; |
| 336 | const drafted = anniversaryWords(deal.address, first(party.name), signOff(agent)); | |
| 340 | const drafted = say({ kind: "anniversary", subject: `Happy home anniversary — ${deal.address}`, first: first(party.name), address: deal.address, signOff: signOff(agent) }, agent.voice); | |
| 337 | 341 | return { |
| 338 | 342 | deal_id: deal.id, |
| 339 | 343 | address: deal.address, |
| @@ -401,10 +405,19 @@ function statusWords(deal: DealView, party: Party, agent: Agent): { subject: str | ||
| 401 | 405 | const next = open.slice(0, NEXT_MAX).map((d) => `• ${d.label}: ${spoken(d.due)} (${owner(d)} · ${PARTY_LABEL[d.owes].toLowerCase()})`); |
| 402 | 406 | const more = open.length > NEXT_MAX ? `\n• …and ${open.length - NEXT_MAX} more after that` : ""; |
| 403 | 407 | const finished = done.length === 0 ? "Nothing is finished yet." : done.map((d) => `• ${d.label}${d.waived_at === null ? "" : " (waived)"}`).join("\n"); |
| 404 | return { | |
| 405 | subject: `${deal.address} — where things stand`, | |
| 406 | body: `Hi ${first(party.name)}, here is where things stand on ${deal.address}.\n\nDone:\n${finished}\n\nNext:\n${next.length === 0 ? "Nothing else before closing." : next.join("\n")}${more}\n\nClosing is ${spoken(closing.due)}${closingAfterDate(deal)} (${relative(closing.due, dayOf(agent))}).\n\nLet me know if you have any questions.\n\n${signOff(agent)}`, | |
| 407 | }; | |
| 408 | return say( | |
| 409 | { | |
| 410 | kind: "status", | |
| 411 | subject: `${deal.address} — where things stand`, | |
| 412 | first: first(party.name), | |
| 413 | address: deal.address, | |
| 414 | signOff: signOff(agent), | |
| 415 | finished, | |
| 416 | next: `${next.length === 0 ? "Nothing else before closing." : next.join("\n")}${more}`, | |
| 417 | closing: `Closing is ${spoken(closing.due)}${closingAfterDate(deal)} (${relative(closing.due, dayOf(agent))}).`, | |
| 418 | }, | |
| 419 | agent.voice, | |
| 420 | ); | |
| 408 | 421 | } |
| 409 | 422 | |
| 410 | 423 | /** |
src/routes.ts +4 −2
| @@ -97,6 +97,7 @@ import { exportDeals, exportName } from "./export.ts"; | ||
| 97 | 97 | import { IMPORT_BYTES, importDeals } from "./import.ts"; |
| 98 | 98 | import { book, calendar } from "./ics.ts"; |
| 99 | 99 | import { agentOf, getProfile, setProfile, useDefaults, type Profile } from "./profile.ts"; |
| 100 | import { voiceSamples } from "./voice.ts"; | |
| 100 | 101 | import { broughtBackPage, profilePage } from "./pages/profile.ts"; |
| 101 | 102 | import { permit } from "./contract.ts"; |
| 102 | 103 | import type { AuditRow } from "./audit.ts"; |
| @@ -526,6 +527,7 @@ export const PROFILE: Schema = { | ||
| 526 | 527 | email: { type: "string", maxLength: 254 }, |
| 527 | 528 | zone: { type: "string", maxLength: 64, description: "The agent's IANA time zone, such as America/New_York: the day \"today\" is told in. Leaving it out on a save keeps the saved zone; a name this runtime does not know is a 422 naming zone." }, |
| 528 | 529 | line: { type: "string", maxLength: 200, description: "One line of the agent's own, no line breaks or control characters, above the sign-off of every new draft; empty for none. On a save, leaving it out keeps the saved line; only an empty string clears it." }, |
| 530 | voice: { type: "string", enum: ["warm", "brief"], description: "How new drafts sound: warm (the default) or brief. Leaving it out on a save keeps the saved voice; any other value is a 422 naming voice. Drafts already sent or held keep their words." }, | |
| 529 | 531 | saved: { type: "boolean", description: "False while the environment's defaults stand." }, |
| 530 | 532 | }, |
| 531 | 533 | required: ["name", "brokerage", "phone", "email"], |
| @@ -1090,7 +1092,7 @@ export const routes: readonly Route[] = [ | ||
| 1090 | 1092 | STATUS[got.error], |
| 1091 | 1093 | clear, |
| 1092 | 1094 | ); |
| 1093 | return html(profilePage({ who: c.who, ...headerFor(c), profile: got.value, mayChange: permit(c.who, "profile", "update") === "yes", notice }), 200, clear); | |
| 1095 | return html(profilePage({ who: c.who, ...headerFor(c), profile: got.value, mayChange: permit(c.who, "profile", "update") === "yes", notice, samples: voiceSamples(c.db, c.who, got.value) }), 200, clear); | |
| 1094 | 1096 | }, |
| 1095 | 1097 | }, |
| 1096 | 1098 | { |
| @@ -1104,7 +1106,7 @@ export const routes: readonly Route[] = [ | ||
| 1104 | 1106 | if (saved.error !== "invalid") return back(saved.message, "/profile"); |
| 1105 | 1107 | const profile = getProfile(c.db, c.who); |
| 1106 | 1108 | if (!profile.ok) return back(saved.message, "/profile"); |
| 1107 | return html(profilePage({ who: c.who, ...headerFor(c), profile: profile.value, mayChange: true, state: { values: f, problem: saved } }), 422); | |
| 1109 | return html(profilePage({ who: c.who, ...headerFor(c), profile: profile.value, mayChange: true, samples: voiceSamples(c.db, c.who, profile.value), state: { values: f, problem: saved } }), 422); | |
| 1108 | 1110 | }, |
| 1109 | 1111 | }, |
| 1110 | 1112 | { |
src/terminal.test.ts +28 −6
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | * The terminal is a second door: each key, pressed against a real |
| 3 | 3 | * database, does what the page's button does, through the same action. |
| 4 | 4 | */ |
| 5 | import { agentOf } from "./profile.ts"; | |
| 5 | 6 | import { expect, test } from "bun:test"; |
| 6 | 7 | import { mkdtempSync } from "node:fs"; |
| 7 | 8 | import { tmpdir } from "node:os"; |
| @@ -692,15 +693,15 @@ test("I shows the profile; an admin edits it and goes back to the defaults, and | ||
| 692 | 693 | const t = terminal(db, root, { title: "deal-clock", where: "test" }); |
| 693 | 694 | press(t, "I"); |
| 694 | 695 | expect(lines(t).join("\n")).toContain("Not saved"); |
| 695 | press(t, "e", "Riley Agent\r", "\r", "555 0199\r", "\r", "Text me anytime\r", "\r"); | |
| 696 | press(t, "e", "Riley Agent\r", "\r", "555 0199\r", "\r", "Text me anytime\r", "\r", "\r"); | |
| 696 | 697 | const saved = lines(t).join("\n"); |
| 697 | 698 | expect(saved).toContain("Profile saved."); |
| 698 | 699 | expect(saved).toContain("Name Riley Agent"); |
| 699 | 700 | expect(saved).toContain("Phone 555 0199"); |
| 700 | 701 | expect(saved).toContain("Own line Text me anytime"); |
| 701 | press(t, "e", "\r", "\r", "\r", "\r", "\r", "\r"); | |
| 702 | press(t, "e", "\r", "\r", "\r", "\r", "\r", "\r", "\r"); | |
| 702 | 703 | expect(lines(t).join("\n")).toContain("Own line Text me anytime"); |
| 703 | press(t, "e", "\r", "\r", "\r", "\r", "-\r", "\r"); | |
| 704 | press(t, "e", "\r", "\r", "\r", "\r", "-\r", "\r", "\r"); | |
| 704 | 705 | expect(lines(t).join("\n")).toContain("Own line none"); |
| 705 | 706 | expect(lines(t)[0]).toContain("Riley Agent"); |
| 706 | 707 | press(t, "U", "yes\r"); |
| @@ -711,16 +712,37 @@ test("I shows the profile; an admin edits it and goes back to the defaults, and | ||
| 711 | 712 | test("the profile editor has a time zone step: it is saved, shown, refused when unknown, and the terminal's today follows it", () => { |
| 712 | 713 | const root: Who = { role: "admin", id: "u0", name: "root" }; |
| 713 | 714 | const t = terminal(db, root, { title: "deal-clock", where: "test" }); |
| 714 | press(t, "I", "e", "\r", "\r", "\r", "\r", "\r", "Nowhere/Land\r"); | |
| 715 | press(t, "I", "e", "\r", "\r", "\r", "\r", "\r", "Nowhere/Land\r", "\r"); | |
| 715 | 716 | expect(lines(t).join("\n")).not.toContain("Time zone Nowhere/Land"); |
| 716 | press(t, "I", "e", "\r", "\r", "\r", "\r", "\r", "Pacific/Kiritimati\r"); | |
| 717 | press(t, "I", "e", "\r", "\r", "\r", "\r", "\r", "Pacific/Kiritimati\r", "\r"); | |
| 717 | 718 | const screen = lines(t).join("\n"); |
| 718 | 719 | expect(screen).toContain("Time zone Pacific/Kiritimati"); |
| 719 | 720 | expect(screen).toContain(spoken(today("Pacific/Kiritimati")) + " ·"); |
| 720 | press(t, "e", "\r", "\r", "\r", "\r", "\r", "America/New_York\r"); | |
| 721 | press(t, "e", "\r", "\r", "\r", "\r", "\r", "America/New_York\r", "\r"); | |
| 721 | 722 | expect(lines(t).join("\n")).toContain(spoken(today("America/New_York")) + " ·"); |
| 722 | 723 | }); |
| 723 | 724 | |
| 725 | test("the profile editor has a voice step: brief is saved and shown, a voice off the list is refused, and a user cannot change it", () => { | |
| 726 | const root: Who = { role: "admin", id: "u0", name: "root" }; | |
| 727 | const t = terminal(db, root, { title: "deal-clock", where: "test" }); | |
| 728 | press(t, "I"); | |
| 729 | expect(lines(t).join("\n")).toContain("Voice warm"); | |
| 730 | press(t, "e", "\r", "\r", "\r", "\r", "\r", "\r", "chatty\r"); | |
| 731 | expect(lines(t).join("\n")).not.toContain("Voice chatty"); | |
| 732 | press(t, "I", "e", "\r", "\r", "\r", "\r", "\r", "\r", "brief\r"); | |
| 733 | expect(lines(t).join("\n")).toContain("Voice brief"); | |
| 734 | expect(agentOf(db).voice).toBe("brief"); | |
| 735 | // Enter keeps the voice already saved; back to warm by name. | |
| 736 | press(t, "e", "\r", "\r", "\r", "\r", "\r", "\r", "\r"); | |
| 737 | expect(lines(t).join("\n")).toContain("Voice brief"); | |
| 738 | press(t, "e", "\r", "\r", "\r", "\r", "\r", "\r", "warm\r"); | |
| 739 | expect(lines(t).join("\n")).toContain("Voice warm"); | |
| 740 | const user = terminal(db, alice, context); | |
| 741 | press(user, "I", "e"); | |
| 742 | expect(lines(user).join("\n")).toContain("Only an admin may change the profile."); | |
| 743 | expect(agentOf(db).voice).toBeUndefined(); | |
| 744 | }); | |
| 745 | ||
| 724 | 746 | test("the profile is read-only to a user and shows nothing to a guest", () => { |
| 725 | 747 | const user = terminal(db, alice, context); |
| 726 | 748 | press(user, "I", "e"); |
src/terminal.ts +6 −2
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | * timeline a deal's dates as a client would get them (t on a deal) |
| 12 | 12 | * file a deal's broker file: figures, parties, every date and message (f on a deal) |
| 13 | 13 | * activity who approved, declined or changed what, newest first (L, admins); m only messages, o older, y newer |
| 14 | * profile your name, brokerage, phone, email, own line and time zone (I); e edits them, U goes back to the defaults | |
| 14 | * profile your name, brokerage, phone, email, own line, time zone and voice (I); e edits them, U goes back to the defaults | |
| 15 | 15 | * deal one deal's dates and parties; a adds a date, p a party, A corrects the address, C sets the closing time and place, M amends the contract, V moves the closing and shifts the dates that hang on it (previewed first), S drafts a status update for the party under the cursor, D removes it, F marks it fell through (or brings it back), Y changes the side (buyer, listing or both), K keeps a private note on the deal (empty clears it) |
| 16 | 16 | * |
| 17 | 17 | * On a date (dates or deal view): x marks it done, W waives it (the buyer gave it up), u reopens it or un-waives it, m moves it |
| @@ -79,6 +79,7 @@ import { find, FIND_MAX, query } from "./find.ts"; | ||
| 79 | 79 | import { mayRestore, restoreSamples } from "./actions.ts"; |
| 80 | 80 | import { conflictLine } from "./order.ts"; |
| 81 | 81 | import { CONTACT_ROLES, isOpen, PARTY_LABEL, type PartyRole } from "./deadlines.ts"; |
| 82 | import { VOICES } from "./wording.ts"; | |
| 82 | 83 | import { agentOf, getProfile, setProfile, useDefaults, zoneOf } from "./profile.ts"; |
| 83 | 84 | import { permit } from "./contract.ts"; |
| 84 | 85 | import { exportDeals, exportName } from "./export.ts"; |
| @@ -596,10 +597,11 @@ export function terminal( | ||
| 596 | 597 | `Email (enter keeps ${now.email})`, |
| 597 | 598 | `A line of your own above the sign-off (enter keeps ${now.line ? `"${now.line}"` : "none"}, - clears it)`, |
| 598 | 599 | `Your time zone (enter keeps ${now.zone ?? DEFAULT_ZONE}; a name such as America/Chicago)`, |
| 600 | `Voice of new drafts (enter keeps ${now.voice ?? "warm"}; ${VOICES.join(" or ")})`, | |
| 599 | 601 | ], |
| 600 | 602 | answers: [], |
| 601 | 603 | text: "", |
| 602 | finish: ([name, brokerage, phone, email, line, zone]) => | |
| 604 | finish: ([name, brokerage, phone, email, line, zone, voice]) => | |
| 603 | 605 | report( |
| 604 | 606 | setProfile(db, me, { |
| 605 | 607 | name: name || now.name, |
| @@ -608,6 +610,7 @@ export function terminal( | ||
| 608 | 610 | email: email || now.email, |
| 609 | 611 | line: line === "-" ? "" : line || (now.line ?? ""), |
| 610 | 612 | zone: zone || now.zone || DEFAULT_ZONE, |
| 613 | voice: voice || now.voice || "warm", | |
| 611 | 614 | }), |
| 612 | 615 | "Profile saved.", |
| 613 | 616 | ), |
| @@ -968,6 +971,7 @@ export function terminal( | ||
| 968 | 971 | ` Email ${a.email}`, |
| 969 | 972 | ` Own line ${a.line ?? dim("none")}`, |
| 970 | 973 | ` Time zone ${a.zone ?? DEFAULT_ZONE}`, |
| 974 | ` Voice ${a.voice ?? "warm"}`, | |
| 971 | 975 | ); |
| 972 | 976 | } |
| 973 | 977 | } else if (view === "messages") { |
src/voice.routes.test.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** The Profile page's voice choice: a form field, same-origin, refused when off the list, with a server-rendered sample of each voice. */ | |
| 2 | import { afterAll, beforeEach, 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 { forgetRationing } from "./security.ts"; | |
| 7 | import { serve } from "./server.ts"; | |
| 8 | import { sign } from "./who.ts"; | |
| 9 | ||
| 10 | const KEY = "a-key-only-the-forge-holds"; | |
| 11 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-voice-routes-")), identityKey: KEY }); | |
| 12 | const base = `http://${server.hostname}:${server.port}`; | |
| 13 | afterAll(() => server.stop(true)); | |
| 14 | ||
| 15 | beforeEach(forgetRationing); | |
| 16 | const identity = `u0|root|admin|${Date.now() + 600_000}`; | |
| 17 | const root = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 18 | const save = (voice: string) => { | |
| 19 | const f = new FormData(); | |
| 20 | for (const [k, v] of Object.entries({ name: "Dana Reyes", brokerage: "Harbor", phone: "555 0100", email: "[email protected]", voice })) f.set(k, v); | |
| 21 | return fetch(`${base}/profile`, { method: "POST", body: f, headers: root, redirect: "manual" }); | |
| 22 | }; | |
| 23 | ||
| 24 | test("the page offers both voices with a sample of each, and saves the one chosen", async () => { | |
| 25 | const before = await (await fetch(`${base}/profile`, { headers: root })).text(); | |
| 26 | expect(before).toContain('name="voice" value="warm" checked'); | |
| 27 | expect(before).toContain("<strong>Warm</strong>"); | |
| 28 | expect(before).toContain("<strong>Brief</strong>"); | |
| 29 | expect((await save("brief")).status).toBe(303); | |
| 30 | const after = await (await fetch(`${base}/profile`, { headers: root })).text(); | |
| 31 | expect(after).toContain('name="voice" value="brief" checked'); | |
| 32 | }); | |
| 33 | ||
| 34 | test("a voice off the list is a 422 naming voice, and a save from another site is refused", async () => { | |
| 35 | const bad = await save("chatty"); | |
| 36 | expect(bad.status).toBe(422); | |
| 37 | expect(await bad.text()).toContain("Your voice is one of"); | |
| 38 | const f = new FormData(); | |
| 39 | f.set("voice", "warm"); | |
| 40 | const cross = await fetch(`${base}/profile`, { method: "POST", body: f, headers: { ...root, "sec-fetch-site": "cross-site" }, redirect: "manual" }); | |
| 41 | expect(cross.status).toBeGreaterThanOrEqual(400); | |
| 42 | }); |
src/voice.test.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { addDays, DEFAULT_ZONE, today } from "./dates.ts"; | |
| 6 | import { openDb } from "./db.ts"; | |
| 7 | import { createDeal, getDeal, holdReminder, sendReminder, type DealView } from "./deals.ts"; | |
| 8 | import { agentOf, getProfile, setProfile } from "./profile.ts"; | |
| 9 | import { drafts, unsent } from "./reminders.ts"; | |
| 10 | import { seed } from "./sample.ts"; | |
| 11 | import { voiceSamples } from "./voice.ts"; | |
| 12 | import type { Who } from "./who.ts"; | |
| 13 | ||
| 14 | const admin: Who = { role: "admin", id: "a1", name: "ann" }; | |
| 15 | const base = { name: "Dana Reyes", brokerage: "Harbor & Pine", phone: "(555) 010-0142", email: "[email protected]" }; | |
| 16 | ||
| 17 | test("the voice is saved, defaults to warm, is refused when not on the list, and survives a save that omits it", () => { | |
| 18 | const db = openDb(mkdtempSync(join(tmpdir(), "app-voice-"))); | |
| 19 | expect(agentOf(db).voice).toBeUndefined(); | |
| 20 | const bad = setProfile(db, admin, { ...base, voice: "chatty" }); | |
| 21 | expect(bad.ok).toBe(false); | |
| 22 | if (!bad.ok) expect(bad.field).toBe("voice"); | |
| 23 | expect(setProfile(db, admin, { ...base, voice: "brief" }).ok).toBe(true); | |
| 24 | expect(agentOf(db).voice).toBe("brief"); | |
| 25 | expect(setProfile(db, admin, base).ok).toBe(true); | |
| 26 | expect(agentOf(db).voice).toBe("brief"); | |
| 27 | expect(setProfile(db, admin, { ...base, voice: "warm" }).ok).toBe(true); | |
| 28 | expect(agentOf(db).voice).toBeUndefined(); | |
| 29 | }); | |
| 30 | ||
| 31 | test("a user may not change the voice", () => { | |
| 32 | const db = openDb(mkdtempSync(join(tmpdir(), "app-voice-who-"))); | |
| 33 | expect(setProfile(db, { role: "user", id: "u1", name: "bo" }, { ...base, voice: "brief" }).ok).toBe(false); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a sent, an edited-and-held and a plainly held draft keep their words when the voice changes", () => { | |
| 37 | const db = openDb(mkdtempSync(join(tmpdir(), "app-voice-held-"))); | |
| 38 | const now = today(DEFAULT_ZONE); | |
| 39 | const made = createDeal(db, admin, { openAnyway: true, address: "9 Later Ln", buyer: "Ann Buyer", seller: "Sam Seller", contractDate: addDays(now, -5), closingDate: addDays(now, 40) }); | |
| 40 | if (!made.ok) throw new Error(made.message); | |
| 41 | const fresh = (): DealView => { | |
| 42 | const got = getDeal(db, admin, { id: made.value.id }); | |
| 43 | if (!got.ok) throw new Error(got.message); | |
| 44 | return got.value; | |
| 45 | }; | |
| 46 | const warm = { ...base, voice: "warm" as const }; | |
| 47 | const due = drafts(fresh(), warm).filter((d) => unsent(d) && d.on <= now); | |
| 48 | const [sent, edited, plain] = due; | |
| 49 | if (sent === undefined || edited === undefined || plain === undefined) throw new Error("the deal needs three reminders due"); | |
| 50 | const ask = (d: typeof sent) => ({ dealId: made.value.id, deadlineId: d.deadline_id, partyId: d.party_id, moment: d.moment, agent: warm }); | |
| 51 | expect(sendReminder(db, admin, ask(sent)).ok).toBe(true); | |
| 52 | expect(holdReminder(db, admin, { ...ask(edited), subject: "My subject", body: "My own words", on: now }).ok).toBe(true); | |
| 53 | expect(holdReminder(db, admin, { ...ask(plain), on: now }).ok).toBe(true); | |
| 54 | const words = (d: typeof sent, voice: "warm" | "brief") => { | |
| 55 | const got = drafts(fresh(), { ...base, voice }).find((x) => x.deadline_id === d.deadline_id && x.party_id === d.party_id && x.moment === d.moment)!; | |
| 56 | return { subject: got.subject, body: got.body }; | |
| 57 | }; | |
| 58 | const before = [sent, edited, plain].map((d) => words(d, "warm")); | |
| 59 | expect(before[1]).toEqual({ subject: "My subject", body: "My own words" }); | |
| 60 | expect(setProfile(db, admin, { ...base, voice: "brief" }).ok).toBe(true); | |
| 61 | expect([sent, edited, plain].map((d) => words(d, "brief"))).toEqual(before); | |
| 62 | // A reminder nobody has touched does take the new voice. | |
| 63 | const untouched = drafts(fresh(), { ...base, voice: "brief" }).find((d) => unsent(d) && d.held === null)!; | |
| 64 | const asWarm = drafts(fresh(), warm).find((d) => d.deadline_id === untouched.deadline_id && d.party_id === untouched.party_id && d.moment === untouched.moment)!; | |
| 65 | expect(untouched.body.length).toBeLessThan(asWarm.body.length); | |
| 66 | }); | |
| 67 | ||
| 68 | test("the profile page samples both voices, brief shorter than warm", () => { | |
| 69 | const db = openDb(mkdtempSync(join(tmpdir(), "app-voice-sample-"))); | |
| 70 | seed(db); | |
| 71 | const got = getProfile(db, admin); | |
| 72 | if (!got.ok) throw new Error(got.message); | |
| 73 | const samples = voiceSamples(db, admin, got.value); | |
| 74 | if (samples === undefined) throw new Error("no sample"); | |
| 75 | expect(samples.brief.length).toBeLessThan(samples.warm.length); | |
| 76 | }); |
src/voice.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * The Profile page's sample of each voice: the first reminder of the first | |
| 3 | * deal the viewer can read that has one, drafted live in warm and in brief | |
| 4 | * for the agent as saved. Plain data out; nothing is stored or sent. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { listDeals, type DealView } from "./deals.ts"; | |
| 9 | import type { Profile } from "./profile.ts"; | |
| 10 | import { drafts, inClockOrder, statusDrafts, unsent, type Agent, type Draft } from "./reminders.ts"; | |
| 11 | import type { Voice } from "./wording.ts"; | |
| 12 | import type { Who } from "./who.ts"; | |
| 13 | ||
| 14 | const say = (d: { subject: string; body: string }): string => `${d.subject}\n\n${d.body}`; | |
| 15 | ||
| 16 | /** The first unsent draft of the first deal that has one, as the agent would send it in a voice. */ | |
| 17 | const firstDraft = (deals: readonly DealView[], agent: Agent, voice: Voice): Draft | undefined => { | |
| 18 | const as = { ...agent, voice }; | |
| 19 | for (const deal of inClockOrder(deals)) { | |
| 20 | const found = [...drafts(deal, as), ...statusDrafts(deal, as)].find(unsent); | |
| 21 | if (found !== undefined) return found; | |
| 22 | } | |
| 23 | return undefined; | |
| 24 | }; | |
| 25 | ||
| 26 | /** Subject and body in each voice, or undefined when no deal has a draft to show. */ | |
| 27 | export function voiceSamples(db: Database, who: Who, profile: Profile): Readonly<Record<Voice, string>> | undefined { | |
| 28 | const deals = listDeals(db, who); | |
| 29 | if (!deals.ok) return undefined; | |
| 30 | const warm = firstDraft(deals.value, profile, "warm"); | |
| 31 | const brief = firstDraft(deals.value, profile, "brief"); | |
| 32 | return warm === undefined || brief === undefined ? undefined : { warm: say(warm), brief: say(brief) }; | |
| 33 | } |
src/wording.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** | |
| 2 | * The words of every draft, in each voice. A draft's facts (names, dates, | |
| 3 | * places, what is owed, the deposit, the sign-off) are built once by | |
| 4 | * src/reminders.ts into one plain `Facts` value; `warm` and `brief` are two | |
| 5 | * small templates over that same value, so a fact is written out once and the | |
| 6 | * voices cannot drift. Subjects are the same in both: only the body differs. | |
| 7 | */ | |
| 8 | ||
| 9 | /** The voices a draft can have: warm is the original wording, brief says the same facts in fewer words. */ | |
| 10 | export const VOICES = ["warm", "brief"] as const; | |
| 11 | export type Voice = (typeof VOICES)[number]; | |
| 12 | export const isVoice = (v: unknown): v is Voice => typeof v === "string" && (VOICES as readonly string[]).includes(v); | |
| 13 | ||
| 14 | /** What every message knows: who it greets, which home, and how it ends. */ | |
| 15 | interface Common { | |
| 16 | readonly subject: string; | |
| 17 | readonly first: string; | |
| 18 | readonly address: string; | |
| 19 | readonly signOff: string; | |
| 20 | } | |
| 21 | ||
| 22 | /** The facts of one message, by kind. Each is plain text already worked out; no template computes a fact. */ | |
| 23 | export type Facts = | |
| 24 | | (Common & { readonly kind: "changes"; readonly changes: string }) | |
| 25 | | (Common & { readonly kind: "moved"; readonly label: string; readonly from: string; readonly to: string; readonly after: string; readonly owed: string }) | |
| 26 | | (Common & { readonly kind: "late"; readonly label: string; readonly due: string; readonly deposit: string; readonly owed: string }) | |
| 27 | | (Common & { readonly kind: "due"; readonly label: string; readonly when: string; readonly deposit: string; readonly owed: string; readonly closes: string; readonly yours: boolean; readonly owner: string }) | |
| 28 | | (Common & { readonly kind: "checkin" }) | |
| 29 | | (Common & { readonly kind: "anniversary" }) | |
| 30 | | (Common & { readonly kind: "status"; readonly finished: string; readonly next: string; readonly closing: string }); | |
| 31 | ||
| 32 | type Template = (facts: Facts) => string; | |
| 33 | ||
| 34 | const warm: Template = (f) => { | |
| 35 | switch (f.kind) { | |
| 36 | case "changes": | |
| 37 | return `Hi ${f.first}, the contract for ${f.address} has been amended, and these dates have changed:\n\n${f.changes}\n\nPlease work to the new dates, and let me know if any of them causes a problem.\n\n${f.signOff}`; | |
| 38 | case "moved": | |
| 39 | return `Hi ${f.first}, a change of date: the ${f.label} for ${f.address} has moved from ${f.from} to ${f.to}${f.after}. ${f.owed}Please work to the new date, and let me know if it causes a problem.\n\n${f.signOff}`; | |
| 40 | case "late": | |
| 41 | return `Hi ${f.first}, the ${f.label} for ${f.address} was due ${f.due} and has passed without being marked done. ${f.deposit}${f.owed}Could you send me an update today on where it stands and when it will be done?\n\n${f.signOff}`; | |
| 42 | case "due": { | |
| 43 | const ask = f.yours | |
| 44 | ? "This one is yours — please let me know right away if anything could hold it up." | |
| 45 | : `${f.owner} is on it; nothing is needed from you unless you hear otherwise.`; | |
| 46 | return `Hi ${f.first}, a reminder that the ${f.label} for ${f.address} is ${f.when}. ${f.deposit}${f.owed}${f.closes}${ask}\n\n${f.signOff}`; | |
| 47 | } | |
| 48 | case "checkin": | |
| 49 | return `Hi ${f.first}, it has been about a month since ${f.address}, and I wanted to ask how the move went. Is the home treating you well?\n\nIf anything has come up since closing, I'm happy to help with it, whether that's a question about the paperwork or a good name for a plumber.\n\nAnd if you know someone thinking of buying or selling, I'd be glad to look after them the way I looked after you. No pressure at all; I'm just glad to have been part of it.\n\n${f.signOff}`; | |
| 50 | case "anniversary": | |
| 51 | return `Hi ${f.first}, happy home anniversary! It was a year ago that ${f.address} became yours, and I was thinking of you today. I hope it feels more like home with every season.\n\nIf you know someone thinking of buying or selling, I'd be glad to look after them the way I looked after you. No pressure at all; I'm just glad to have been part of it.\n\n${f.signOff}`; | |
| 52 | case "status": | |
| 53 | return `Hi ${f.first}, here is where things stand on ${f.address}.\n\nDone:\n${f.finished}\n\nNext:\n${f.next}\n\n${f.closing}\n\nLet me know if you have any questions.\n\n${f.signOff}`; | |
| 54 | } | |
| 55 | }; | |
| 56 | ||
| 57 | const brief: Template = (f) => { | |
| 58 | switch (f.kind) { | |
| 59 | case "changes": | |
| 60 | return `Hi ${f.first}, dates changed on ${f.address}:\n\n${f.changes}\n\nPlease work to the new dates.\n\n${f.signOff}`; | |
| 61 | case "moved": | |
| 62 | return `Hi ${f.first}, the ${f.label} for ${f.address} moved from ${f.from} to ${f.to}${f.after}. ${f.owed}Please work to the new date.\n\n${f.signOff}`; | |
| 63 | case "late": | |
| 64 | return `Hi ${f.first}, the ${f.label} for ${f.address} was due ${f.due} and is not marked done. ${f.deposit}${f.owed}Please send me an update today.\n\n${f.signOff}`; | |
| 65 | case "due": { | |
| 66 | const ask = f.yours ? "This one is yours; tell me if anything could hold it up." : `${f.owner} is on it; nothing is needed from you.`; | |
| 67 | return `Hi ${f.first}, a reminder that the ${f.label} for ${f.address} is ${f.when}. ${f.deposit}${f.owed}${f.closes}${ask}\n\n${f.signOff}`; | |
| 68 | } | |
| 69 | case "checkin": | |
| 70 | return `Hi ${f.first}, how is the new home? It has been about a month since ${f.address}. Tell me if anything has come up.\n\n${f.signOff}`; | |
| 71 | case "anniversary": | |
| 72 | return `Hi ${f.first}, happy home anniversary on ${f.address}. Thinking of you.\n\n${f.signOff}`; | |
| 73 | case "status": | |
| 74 | return `Hi ${f.first}, where things stand on ${f.address}.\n\nDone:\n${f.finished}\n\nNext:\n${f.next}\n\n${f.closing}\n\n${f.signOff}`; | |
| 75 | } | |
| 76 | }; | |
| 77 | ||
| 78 | const TEMPLATES: Readonly<Record<Voice, Template>> = { warm, brief }; | |
| 79 | ||
| 80 | /** The subject and body of a message in a voice; an absent voice is warm. */ | |
| 81 | export function say(facts: Facts, voice: Voice = "warm"): { subject: string; body: string } { | |
| 82 | return { subject: facts.subject, body: TEMPLATES[voice](facts) }; | |
| 83 | } |
new above
offline
This test does not do what its title says: it never sends or holds a draft nor saves a different voice afterwards. Make it assert that a sent, edited and held draft keeps its words after the voice changes.