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

Merged opened this

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

Merged

mason/deal-clock:mason/297-the-agent-chooses-a-warm-or-a-brief-voic → main 5 commits fork 1 thread · 1 unresolved

  • Merged. mason/297-the-agent-chooses-a-warm-or-a-brief-voic was merged into main as 7473f8f.

Review

Sign in to review this change.
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
232232 deal sets how many days before a date its reminder goes (1 to 30), and your
233233 Profile may add one line of your own to the end of every message ("Text me
234234 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
236241 contract lengths once; see the technical page. On your phone, choose Add to
237242 Home Screen: the deal-clock mark sits beside the name and it opens full screen.
238243
contract/openapi.json +32
@@ -46,6 +46,14 @@
4646 "maxLength": 200,
4747 "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."
4848 },
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 },
4957 "saved": {
5058 "type": "boolean",
5159 "description": "False while the environment's defaults stand."
@@ -116,6 +124,14 @@
116124 "maxLength": 200,
117125 "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."
118126 },
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 },
119135 "saved": {
120136 "type": "boolean",
121137 "description": "False while the environment's defaults stand."
@@ -167,6 +183,14 @@
167183 "maxLength": 200,
168184 "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."
169185 },
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 },
170194 "saved": {
171195 "type": "boolean",
172196 "description": "False while the environment's defaults stand."
@@ -238,6 +262,14 @@
238262 "maxLength": 200,
239263 "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."
240264 },
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 },
241273 "saved": {
242274 "type": "boolean",
243275 "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.
281281 | `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 |
282282 | `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 |
283283 | `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 |
285285 | `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 |
286286 | `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 |
287287 | `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[] {
1919 return side === "buyer" ? ["buyer"] : side === "listing" ? ["seller"] : ["buyer", "seller"];
2020 }
2121
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
3022 // ---- the home anniversary: each year after the closing, to the same clients ----
3123
3224 /** 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 {
4335 const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
4436 return `${String(y).padStart(4, "0")}-${md === "02-29" && !leap ? "02-28" : md}`;
4537 }
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[] = [
588588 `ALTER TABLE deadline_move ADD COLUMN undo INTEGER NOT NULL DEFAULT 0`,
589589 // The agent's private note on a deal: for their eyes only, never in a draft, timeline or handout.
590590 `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'))`,
591593 ];
592594
593595 export function openDb(dir = dataDir): Database {
src/deals.ts +1 −1
@@ -1541,7 +1541,7 @@ export function holdReminder(
15411541 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,
15421542 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 *`,
15431543 )
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)!;
15451545 return within(db, who, `reminder.hold:${until}`, dealId, ok(held));
15461546 }
15471547
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",
6666 expect(all.flatMap((d) => leaks(d, agent))).toEqual([]);
6767 }
6868 });
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[] = [
560560 phone: { type: "string", maxLength: 30 },
561561 email: { type: "string", maxLength: 254 },
562562 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." },
563564 },
564565 required: ["name", "brokerage", "phone", "email"],
565566 },
src/migrations.test.ts +1 −1
@@ -13,7 +13,7 @@ test("every migration is pinned by position; a pull may only append", () => {
1313 });
1414
1515 test("a database made before the Profile line gets the column and reads the agent", () => {
16 const before = migrations.filter((m) => !/profile ADD COLUMN line|commission_rate|profile ADD COLUMN zone|ADD COLUMN cash|'anniversary'|deadline ADD COLUMN (time|place)|party ADD COLUMN reach|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));
1717 const db = new Database(":memory:");
1818 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))");
1919 before.forEach((sql, i) => {
src/pages/profile.ts +11 −1
@@ -8,6 +8,7 @@ import { DEFAULT_ZONE, US_ZONES } from "../dates.ts";
88 import { escape } from "../http.ts";
99 import type { Profile } from "../profile.ts";
1010 import type { Agent } from "../reminders.ts";
11 import { VOICES, type Voice } from "../wording.ts";
1112 import type { Who } from "../who.ts";
1213 import { layout } from "./layout.ts";
1314
@@ -26,9 +27,11 @@ export function profilePage(input: {
2627 /** Whether the table lets `who` save it. */
2728 mayChange: boolean;
2829 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;
2932 state?: ProfileState | undefined;
3033 }): string {
31 const { who, profile, mayChange, notice, state } = input;
34 const { who, profile, mayChange, notice, state, samples } = input;
3235 const agent: Agent = profile;
3336 const values = state?.values ?? {};
3437 const problem = state?.problem;
@@ -43,6 +46,10 @@ export function profilePage(input: {
4346 const zone = values.zone ?? agent.zone ?? DEFAULT_ZONE;
4447 const zones = US_ZONES.some(([name]) => name === zone) ? US_ZONES : [...US_ZONES, [zone, zone] as const];
4548 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>`;
4653 const general =
4754 problem !== undefined && problem.field === undefined
4855 ? `<p class="field-error" role="alert">${escape(problem.message)}</p>`
@@ -55,6 +62,7 @@ export function profilePage(input: {
5562 <label>Email<input class="input" type="email" name="email" required maxlength="254" autocomplete="email" value="${val("email")}"${bad("email")}>${why("email")}</label>
5663 <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>
5764 <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>
5866 <div class="actions">
5967 <button class="button">Save profile</button>
6068 <button class="button button-quiet" formaction="/profile/defaults" formnovalidate>Use defaults</button>
@@ -66,6 +74,7 @@ export function profilePage(input: {
6674 <div><dt class="muted small">Phone</dt><dd>${escape(agent.phone)}</dd></div>
6775 <div><dt class="muted small">Email</dt><dd>${escape(agent.email)}</dd></div>
6876 <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>
6978 <div><dt class="muted small">A line of your own</dt><dd>${escape(agent.line ?? "") || "None"}</dd></div>
7079 </dl>
7180 <p class="muted small">Only an admin can change these.</p>`;
@@ -80,6 +89,7 @@ export function profilePage(input: {
8089 <h1>Profile</h1>
8190 <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>
8291 ${mayChange ? form : readOnly}
92 ${samples === undefined ? "" : `<h2>How each voice reads</h2>${sample("warm")}${sample("brief")}`}
8393 <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>
8494 ${bringBack}
8595 </section>`;
src/profile.ts +13 −7
@@ -10,6 +10,7 @@ import type { Database } from "bun:sqlite";
1010 import { DEFAULT_ZONE, isZone } from "./dates.ts";
1111 import { agent as defaults, validEmail, validPhone } from "./env.ts";
1212 import type { Agent } from "./reminders.ts";
13 import { isVoice, VOICES, type Voice } from "./wording.ts";
1314 import { audited, fail, ok, refuse, type Result } from "./result.ts";
1415 import { permit } from "./contract.ts";
1516 import type { Who } from "./who.ts";
@@ -25,11 +26,12 @@ const FIELDS = ["name", "brokerage", "phone", "email"] as const;
2526 /** The agent the page, the drafts and the files name: the saved row, else the defaults. */
2627 export function agentOf(db: Database): Agent {
2728 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")
2930 .get();
3031 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 } : {}) };
3335 }
3436
3537 /** 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> {
6264 // A client that predates the zone does not send it: the zone stands as it is (`setProfile`).
6365 const zone = typeof input.zone === "string" ? input.zone.trim() : DEFAULT_ZONE;
6466 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 } : {}) });
6671 }
6772
6873 const may = (who: Who): Result<null> =>
@@ -91,15 +96,16 @@ export function setProfile(db: Database, who: Who, input: Record<string, unknown
9196 ...input,
9297 ...(typeof input.line === "string" ? {} : { line: now.line ?? "" }),
9398 ...(typeof input.zone === "string" ? {} : { zone: now.zone ?? DEFAULT_ZONE }),
99 ...(typeof input.voice === "string" ? {} : { voice: now.voice ?? "warm" }),
94100 };
95101 const checked = checkProfile(given);
96102 if (!checked.ok) out = checked;
97103 else {
98104 const a = checked.value;
99105 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");
103109 zones.delete(db);
104110 out = ok({ ...a, id: 1, saved: true });
105111 }
src/reminders.ts +44 −31
@@ -11,7 +11,8 @@
1111 import { addDays, daysBetween, dayOf, dayOfStamp, relative, short, spoken, today, weekday } from "./dates.ts";
1212 import { appointmentAfterDate } from "./appointment.ts";
1313 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";
1516 import { dollars } from "./money.ts";
1617 import { memoizer, print } from "./memo.ts";
1718 import { DEFAULT_LEAD, isInternal, isOpen, KIND, notifiedOf, PARTY_LABEL, type PartyRole } from "./deadlines.ts";
@@ -26,6 +27,8 @@ export interface Agent {
2627 readonly line?: string | undefined;
2728 /** The IANA zone the agent's day is told in; absent reads as `DEFAULT_ZONE`. */
2829 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;
2932 }
3033
3134 /** How every draft ends: the agent's own line if set, then who to call or write back. */
@@ -155,6 +158,7 @@ function words(
155158 agent: Agent,
156159 notice: Notice | undefined,
157160 ): { subject: string; body: string } {
161 const common = { first: first(party.name), address: deal.address, signOff: signOff(agent) };
158162 const owner = deal.parties.find((p) => p.role === deadline.owes);
159163 const owed = deadline.what === "" ? "" : `${deadline.what}. `;
160164 const sum = deadline.kind === "earnest" && deal.earnest_amount !== null ? deal.earnest_amount : null;
@@ -163,39 +167,39 @@ function words(
163167 sum === null
164168 ? ""
165169 : `${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;
166173 if (moment === "moved" && notice !== undefined && notice.items.length > 1) {
167174 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)}`)
169176 .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);
174178 }
175179 if (moment === "moved") {
176180 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);
181182 }
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)}`;
189186 // The walk-through is the day before the keys change hands: it says where and when that is.
190187 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 );
199203 }
200204
201205 /**
@@ -283,7 +287,7 @@ export function checkinDrafts(deal: DealView, agent: Agent): Draft[] {
283287 const sent = deal.sent.find(mine);
284288 const declined = deal.declined.find(mine);
285289 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);
287291 return {
288292 deal_id: deal.id,
289293 address: deal.address,
@@ -333,7 +337,7 @@ export function anniversaryDrafts(deal: DealView, agent: Agent): Draft[] {
333337 const sent = deal.sent.find(mine);
334338 const declined = deal.declined.find(mine);
335339 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);
337341 return {
338342 deal_id: deal.id,
339343 address: deal.address,
@@ -401,10 +405,19 @@ function statusWords(deal: DealView, party: Party, agent: Agent): { subject: str
401405 const next = open.slice(0, NEXT_MAX).map((d) => `• ${d.label}: ${spoken(d.due)} (${owner(d)} · ${PARTY_LABEL[d.owes].toLowerCase()})`);
402406 const more = open.length > NEXT_MAX ? `\n• …and ${open.length - NEXT_MAX} more after that` : "";
403407 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 );
408421 }
409422
410423 /**
src/routes.ts +4 −2
@@ -97,6 +97,7 @@ import { exportDeals, exportName } from "./export.ts";
9797 import { IMPORT_BYTES, importDeals } from "./import.ts";
9898 import { book, calendar } from "./ics.ts";
9999 import { agentOf, getProfile, setProfile, useDefaults, type Profile } from "./profile.ts";
100 import { voiceSamples } from "./voice.ts";
100101 import { broughtBackPage, profilePage } from "./pages/profile.ts";
101102 import { permit } from "./contract.ts";
102103 import type { AuditRow } from "./audit.ts";
@@ -526,6 +527,7 @@ export const PROFILE: Schema = {
526527 email: { type: "string", maxLength: 254 },
527528 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." },
528529 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." },
529531 saved: { type: "boolean", description: "False while the environment's defaults stand." },
530532 },
531533 required: ["name", "brokerage", "phone", "email"],
@@ -1090,7 +1092,7 @@ export const routes: readonly Route[] = [
10901092 STATUS[got.error],
10911093 clear,
10921094 );
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);
10941096 },
10951097 },
10961098 {
@@ -1104,7 +1106,7 @@ export const routes: readonly Route[] = [
11041106 if (saved.error !== "invalid") return back(saved.message, "/profile");
11051107 const profile = getProfile(c.db, c.who);
11061108 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);
11081110 },
11091111 },
11101112 {
src/terminal.test.ts +28 −6
@@ -2,6 +2,7 @@
22 * The terminal is a second door: each key, pressed against a real
33 * database, does what the page's button does, through the same action.
44 */
5 import { agentOf } from "./profile.ts";
56 import { expect, test } from "bun:test";
67 import { mkdtempSync } from "node:fs";
78 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
692693 const t = terminal(db, root, { title: "deal-clock", where: "test" });
693694 press(t, "I");
694695 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");
696697 const saved = lines(t).join("\n");
697698 expect(saved).toContain("Profile saved.");
698699 expect(saved).toContain("Name Riley Agent");
699700 expect(saved).toContain("Phone 555 0199");
700701 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");
702703 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");
704705 expect(lines(t).join("\n")).toContain("Own line none");
705706 expect(lines(t)[0]).toContain("Riley Agent");
706707 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
711712 test("the profile editor has a time zone step: it is saved, shown, refused when unknown, and the terminal's today follows it", () => {
712713 const root: Who = { role: "admin", id: "u0", name: "root" };
713714 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");
715716 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");
717718 const screen = lines(t).join("\n");
718719 expect(screen).toContain("Time zone Pacific/Kiritimati");
719720 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");
721722 expect(lines(t).join("\n")).toContain(spoken(today("America/New_York")) + " ·");
722723 });
723724
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
724746 test("the profile is read-only to a user and shows nothing to a guest", () => {
725747 const user = terminal(db, alice, context);
726748 press(user, "I", "e");
src/terminal.ts +6 −2
@@ -11,7 +11,7 @@
1111 * timeline a deal's dates as a client would get them (t on a deal)
1212 * file a deal's broker file: figures, parties, every date and message (f on a deal)
1313 * 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
1515 * 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)
1616 *
1717 * 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";
7979 import { mayRestore, restoreSamples } from "./actions.ts";
8080 import { conflictLine } from "./order.ts";
8181 import { CONTACT_ROLES, isOpen, PARTY_LABEL, type PartyRole } from "./deadlines.ts";
82 import { VOICES } from "./wording.ts";
8283 import { agentOf, getProfile, setProfile, useDefaults, zoneOf } from "./profile.ts";
8384 import { permit } from "./contract.ts";
8485 import { exportDeals, exportName } from "./export.ts";
@@ -596,10 +597,11 @@ export function terminal(
596597 `Email (enter keeps ${now.email})`,
597598 `A line of your own above the sign-off (enter keeps ${now.line ? `"${now.line}"` : "none"}, - clears it)`,
598599 `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 ")})`,
599601 ],
600602 answers: [],
601603 text: "",
602 finish: ([name, brokerage, phone, email, line, zone]) =>
604 finish: ([name, brokerage, phone, email, line, zone, voice]) =>
603605 report(
604606 setProfile(db, me, {
605607 name: name || now.name,
@@ -608,6 +610,7 @@ export function terminal(
608610 email: email || now.email,
609611 line: line === "-" ? "" : line || (now.line ?? ""),
610612 zone: zone || now.zone || DEFAULT_ZONE,
613 voice: voice || now.voice || "warm",
611614 }),
612615 "Profile saved.",
613616 ),
@@ -968,6 +971,7 @@ export function terminal(
968971 ` Email ${a.email}`,
969972 ` Own line ${a.line ?? dim("none")}`,
970973 ` Time zone ${a.zone ?? DEFAULT_ZONE}`,
974 ` Voice ${a.voice ?? "warm"}`,
971975 );
972976 }
973977 } 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", () => {
outdated

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.

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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs