Public: anyone can read this; the crew can change it.
A lead who buys or sells with the agent is marked closed with a date, and a year later a home-anniversary check-in draft waits on the desk #110
Check · Review · Land · Live — It conflicts with its base.
first in the queue: no estimate: nothing landed in the last hour
mason/speed-to-lead:mason/90-a-lead-who-buys-or-sells-with-the-agent → main 1 commit fork
22 files
changed +772
−47
AGENTS.md +8
| @@ -140,6 +140,14 @@ Speed to Lead is one realtor's desk. The concepts, one module each: | ||
| 140 | 140 | not touch the cadence. `starredFirst` lifts starred open leads to the top of |
| 141 | 141 | any view but leaves a first reply past the green clock ahead of them, and does |
| 142 | 142 | nothing while nobody is starred. `show=starred` is a view of its own. |
| 143 | - Closed deals: a lead who bought or sold with the agent (`src/closed.ts`, the | |
| 144 | `closed_kind` and `closed_on` columns). The state stays `qualified` (nothing | |
| 145 | more is scheduled, `reopen` undoes it and clears both), so the state CHECK | |
| 146 | needs no rebuild. `anniversaries` (run by `turn`) drafts one "happy home | |
| 147 | anniversary" check-in on each anniversary of `closed_on` in the agent's | |
| 148 | zone, numbered `ANNIVERSARY_DAY` + years (101…199) so a year never gets two. | |
| 149 | It is a `draft`, never cadence: it shows under *Needs you* (`inView`) though | |
| 150 | the lead is closed, and only an approval sends it. | |
| 143 | 151 | - `src/thanks.ts` — the thank-you to whoever referred a lead (`referred_by`, |
| 144 | 152 | `referrer_email`, `referrer_phone` on the lead; `referrer_thanks` table, one |
| 145 | 153 | per lead). It is deliberately not a `message`: it goes to someone who is not |
README.md +12
| @@ -86,6 +86,7 @@ what you see right after a restart. Do step 1 first. | ||
| 86 | 86 | day; at 8:00 that morning in your time zone a check-in draft waits for you |
| 87 | 87 | to approve. **Hannah Wright** is already put away for sixty days. "Bring |
| 88 | 88 | back now" clears the day, and so does a lead writing back. |
| 89 | A lead who buys or sells with you can be marked closed, bought or sold, with the closing date. Follow-ups stop, and each year on that date a short "happy home anniversary" note waits in *Needs you* until you approve it. **Tom Whitfield** is a past client whose anniversary is today. Putting the lead back in follow-ups undoes it. | |
| 89 | 90 | Keep your best leads close with the star: tap ☆ on a desk row or under a |
| 90 | 91 | lead's name (one tap, no JavaScript needed). Starred open leads sort first |
| 91 | 92 | under every filter, and *Starred* is a filter of its own. A first reply |
| @@ -394,6 +395,17 @@ for a bad date), the MCP tool `put_lead_away`, and `a` (put away) and `A` | ||
| 394 | 395 | change to a lead, and the Activity page says "put … away until …" and "brought |
| 395 | 396 | … back". |
| 396 | 397 | |
| 398 | A deal closed: `PATCH /leads/:id` (or `POST /leads/:id/state`) with | |
| 399 | `{"state": "closed", "kind": "bought", "closed_on": "2026-03-01"}` (`kind` is | |
| 400 | `bought` or `sold`; `422` naming `kind` or `closed_on` for a bad one, a date | |
| 401 | more than a year ahead included), the MCP tool `mark_lead`, and `y` on a | |
| 402 | lead's terminal screen. The lead stays `qualified` with `closedKind` and | |
| 403 | `closedOn` set; `state: "open"` undoes it. Each year on `closedOn` in your time | |
| 404 | zone the clock drafts one check-in (`turn` runs it), once per year, which waits | |
| 405 | in *Needs you* until approved. The Activity page counts deals closed in 30 days | |
| 406 | beside the lost reasons and the CSV has `closed` and `closed on` columns. It | |
| 407 | needs the same permission as any change to a lead; no contract cell changed. | |
| 408 | ||
| 397 | 409 | Starring a lead keeps it close: `PATCH /leads/:id` with `{"starred": true}` (or |
| 398 | 410 | `false`; `422` for anything else), the page's `POST /leads/:id/star`, the MCP |
| 399 | 411 | tool `star_lead`, and `*` on the terminal's desk and lead screens. A lead's JSON |
contract/openapi.json +100 −4
| @@ -1334,7 +1334,7 @@ | ||
| 1334 | 1334 | } |
| 1335 | 1335 | }, |
| 1336 | 1336 | "patch": { |
| 1337 | "summary": "Mark a lead qualified or lost (the same as POST /leads/{id}/state), correct its name, email or phone (the same as POST /leads/{id}/edit), keep a private note on it (the same as POST /leads/{id}/note), star it or take the star off with starred (true or false), or put it away until a day with back_on (YYYY-MM-DD, from tomorrow to 365 days out in the profile's time zone; null brings it back now): send one of the five. Putting it away sets unsent follow-ups aside, and at 8:00 that morning a check-in draft waits on the desk; a bad date is a 422 naming back_on.", | |
| 1337 | "summary": "Mark a lead qualified, lost or closed (bought or sold, with a closing date; the same as POST /leads/{id}/state), correct its name, email or phone (the same as POST /leads/{id}/edit), keep a private note on it (the same as POST /leads/{id}/note), star it or take the star off with starred (true or false), or put it away until a day with back_on (YYYY-MM-DD, from tomorrow to 365 days out in the profile's time zone; null brings it back now): send one of the five. Putting it away sets unsent follow-ups aside, and at 8:00 that morning a check-in draft waits on the desk; a bad date is a 422 naming back_on.", | |
| 1338 | 1338 | "operationId": "patchLeadsId", |
| 1339 | 1339 | "parameters": [ |
| 1340 | 1340 | { |
| @@ -1357,6 +1357,7 @@ | ||
| 1357 | 1357 | "enum": [ |
| 1358 | 1358 | "qualified", |
| 1359 | 1359 | "lost", |
| 1360 | "closed", | |
| 1360 | 1361 | "open" |
| 1361 | 1362 | ] |
| 1362 | 1363 | }, |
| @@ -1370,6 +1371,17 @@ | ||
| 1370 | 1371 | ], |
| 1371 | 1372 | "description": "Why the lead is lost; required when state is lost." |
| 1372 | 1373 | }, |
| 1374 | "kind": { | |
| 1375 | "enum": [ | |
| 1376 | "bought", | |
| 1377 | "sold" | |
| 1378 | ], | |
| 1379 | "description": "Whether the client bought or sold with the agent; required when state is closed." | |
| 1380 | }, | |
| 1381 | "closed_on": { | |
| 1382 | "type": "string", | |
| 1383 | "description": "The closing date, YYYY-MM-DD, not more than a year from today in the profile's time zone; required when state is closed (422 naming closed_on otherwise)." | |
| 1384 | }, | |
| 1373 | 1385 | "name": { |
| 1374 | 1386 | "type": "string", |
| 1375 | 1387 | "minLength": 1, |
| @@ -1407,7 +1419,7 @@ | ||
| 1407 | 1419 | }, |
| 1408 | 1420 | "responses": { |
| 1409 | 1421 | "200": { |
| 1410 | "description": "Mark a lead qualified or lost (the same as POST /leads/{id}/state), correct its name, email or phone (the same as POST /leads/{id}/edit), keep a private note on it (the same as POST /leads/{id}/note), star it or take the star off with starred (true or false), or put it away until a day with back_on (YYYY-MM-DD, from tomorrow to 365 days out in the profile's time zone; null brings it back now): send one of the five. Putting it away sets unsent follow-ups aside, and at 8:00 that morning a check-in draft waits on the desk; a bad date is a 422 naming back_on.", | |
| 1422 | "description": "Mark a lead qualified, lost or closed (bought or sold, with a closing date; the same as POST /leads/{id}/state), correct its name, email or phone (the same as POST /leads/{id}/edit), keep a private note on it (the same as POST /leads/{id}/note), star it or take the star off with starred (true or false), or put it away until a day with back_on (YYYY-MM-DD, from tomorrow to 365 days out in the profile's time zone; null brings it back now): send one of the five. Putting it away sets unsent follow-ups aside, and at 8:00 that morning a check-in draft waits on the desk; a bad date is a 422 naming back_on.", | |
| 1411 | 1423 | "content": { |
| 1412 | 1424 | "application/json": { |
| 1413 | 1425 | "schema": { |
| @@ -1602,6 +1614,28 @@ | ||
| 1602 | 1614 | ], |
| 1603 | 1615 | "description": "Why the lead was lost; null unless it is lost, and for a lead lost before reasons were asked for." |
| 1604 | 1616 | }, |
| 1617 | "closedKind": { | |
| 1618 | "anyOf": [ | |
| 1619 | { | |
| 1620 | "enum": [ | |
| 1621 | "bought", | |
| 1622 | "sold" | |
| 1623 | ] | |
| 1624 | }, | |
| 1625 | { | |
| 1626 | "type": "null" | |
| 1627 | } | |
| 1628 | ], | |
| 1629 | "description": "For a deal closed: bought or sold with the agent; null otherwise." | |
| 1630 | }, | |
| 1631 | "closedOn": { | |
| 1632 | "type": [ | |
| 1633 | "string", | |
| 1634 | "null" | |
| 1635 | ], | |
| 1636 | "pattern": "^\\d{4}-\\d{2}-\\d{2}$", | |
| 1637 | "description": "For a deal closed: the closing date (YYYY-MM-DD, the profile's calendar); each year on it a home-anniversary check-in draft waits on the desk. Null otherwise." | |
| 1638 | }, | |
| 1605 | 1639 | "lostAt": { |
| 1606 | 1640 | "type": [ |
| 1607 | 1641 | "string", |
| @@ -1865,6 +1899,8 @@ | ||
| 1865 | 1899 | "opted_out_at", |
| 1866 | 1900 | "state", |
| 1867 | 1901 | "lostReason", |
| 1902 | "closedKind", | |
| 1903 | "closedOn", | |
| 1868 | 1904 | "lostAt", |
| 1869 | 1905 | "back_on", |
| 1870 | 1906 | "starred", |
| @@ -2305,7 +2341,7 @@ | ||
| 2305 | 2341 | }, |
| 2306 | 2342 | "/leads/{id}/state": { |
| 2307 | 2343 | "post": { |
| 2308 | "summary": "Mark a lead qualified or lost (with a reason): it leaves the follow-up plan. 'open' puts a closed lead back in it.", | |
| 2344 | "summary": "Mark a lead qualified, lost (with a reason) or closed (kind bought or sold, and closed_on, the closing date): it leaves the follow-up plan. A closed deal gets a home-anniversary check-in draft on the desk each year on its date. 'open' puts a closed lead back in it.", | |
| 2309 | 2345 | "operationId": "postLeadsIdState", |
| 2310 | 2346 | "parameters": [ |
| 2311 | 2347 | { |
| @@ -2328,6 +2364,7 @@ | ||
| 2328 | 2364 | "enum": [ |
| 2329 | 2365 | "qualified", |
| 2330 | 2366 | "lost", |
| 2367 | "closed", | |
| 2331 | 2368 | "open" |
| 2332 | 2369 | ] |
| 2333 | 2370 | }, |
| @@ -2340,6 +2377,17 @@ | ||
| 2340 | 2377 | "other" |
| 2341 | 2378 | ], |
| 2342 | 2379 | "description": "Why the lead is lost; required when state is lost." |
| 2380 | }, | |
| 2381 | "kind": { | |
| 2382 | "enum": [ | |
| 2383 | "bought", | |
| 2384 | "sold" | |
| 2385 | ], | |
| 2386 | "description": "Whether the client bought or sold with the agent; required when state is closed." | |
| 2387 | }, | |
| 2388 | "closed_on": { | |
| 2389 | "type": "string", | |
| 2390 | "description": "The closing date, YYYY-MM-DD, not more than a year from today in the profile's time zone; required when state is closed (422 naming closed_on otherwise)." | |
| 2343 | 2391 | } |
| 2344 | 2392 | }, |
| 2345 | 2393 | "required": [ |
| @@ -2351,7 +2399,7 @@ | ||
| 2351 | 2399 | }, |
| 2352 | 2400 | "responses": { |
| 2353 | 2401 | "200": { |
| 2354 | "description": "Mark a lead qualified or lost (with a reason): it leaves the follow-up plan. 'open' puts a closed lead back in it.", | |
| 2402 | "description": "Mark a lead qualified, lost (with a reason) or closed (kind bought or sold, and closed_on, the closing date): it leaves the follow-up plan. A closed deal gets a home-anniversary check-in draft on the desk each year on its date. 'open' puts a closed lead back in it.", | |
| 2355 | 2403 | "content": { |
| 2356 | 2404 | "application/json": { |
| 2357 | 2405 | "schema": { |
| @@ -3441,6 +3489,28 @@ | ||
| 3441 | 3489 | ], |
| 3442 | 3490 | "description": "Why the lead was lost; null unless it is lost, and for a lead lost before reasons were asked for." |
| 3443 | 3491 | }, |
| 3492 | "closedKind": { | |
| 3493 | "anyOf": [ | |
| 3494 | { | |
| 3495 | "enum": [ | |
| 3496 | "bought", | |
| 3497 | "sold" | |
| 3498 | ] | |
| 3499 | }, | |
| 3500 | { | |
| 3501 | "type": "null" | |
| 3502 | } | |
| 3503 | ], | |
| 3504 | "description": "For a deal closed: bought or sold with the agent; null otherwise." | |
| 3505 | }, | |
| 3506 | "closedOn": { | |
| 3507 | "type": [ | |
| 3508 | "string", | |
| 3509 | "null" | |
| 3510 | ], | |
| 3511 | "pattern": "^\\d{4}-\\d{2}-\\d{2}$", | |
| 3512 | "description": "For a deal closed: the closing date (YYYY-MM-DD, the profile's calendar); each year on it a home-anniversary check-in draft waits on the desk. Null otherwise." | |
| 3513 | }, | |
| 3444 | 3514 | "lostAt": { |
| 3445 | 3515 | "type": [ |
| 3446 | 3516 | "string", |
| @@ -3680,6 +3750,8 @@ | ||
| 3680 | 3750 | "opted_out_at", |
| 3681 | 3751 | "state", |
| 3682 | 3752 | "lostReason", |
| 3753 | "closedKind", | |
| 3754 | "closedOn", | |
| 3683 | 3755 | "lostAt", |
| 3684 | 3756 | "back_on", |
| 3685 | 3757 | "starred", |
| @@ -3932,6 +4004,28 @@ | ||
| 3932 | 4004 | ], |
| 3933 | 4005 | "description": "Why the lead was lost; null unless it is lost, and for a lead lost before reasons were asked for." |
| 3934 | 4006 | }, |
| 4007 | "closedKind": { | |
| 4008 | "anyOf": [ | |
| 4009 | { | |
| 4010 | "enum": [ | |
| 4011 | "bought", | |
| 4012 | "sold" | |
| 4013 | ] | |
| 4014 | }, | |
| 4015 | { | |
| 4016 | "type": "null" | |
| 4017 | } | |
| 4018 | ], | |
| 4019 | "description": "For a deal closed: bought or sold with the agent; null otherwise." | |
| 4020 | }, | |
| 4021 | "closedOn": { | |
| 4022 | "type": [ | |
| 4023 | "string", | |
| 4024 | "null" | |
| 4025 | ], | |
| 4026 | "pattern": "^\\d{4}-\\d{2}-\\d{2}$", | |
| 4027 | "description": "For a deal closed: the closing date (YYYY-MM-DD, the profile's calendar); each year on it a home-anniversary check-in draft waits on the desk. Null otherwise." | |
| 4028 | }, | |
| 3935 | 4029 | "lostAt": { |
| 3936 | 4030 | "type": [ |
| 3937 | 4031 | "string", |
| @@ -4072,6 +4166,8 @@ | ||
| 4072 | 4166 | "opted_out_at", |
| 4073 | 4167 | "state", |
| 4074 | 4168 | "lostReason", |
| 4169 | "closedKind", | |
| 4170 | "closedOn", | |
| 4075 | 4171 | "lostAt", |
| 4076 | 4172 | "back_on", |
| 4077 | 4173 | "starred", |
src/actions.test.ts +5 −4
| @@ -226,10 +226,10 @@ test("qualified or lost leaves the cadence", () => { | ||
| 226 | 226 | if (!gone.ok) expect(gone.error).toBe("not_found"); |
| 227 | 227 | }); |
| 228 | 228 | |
| 229 | test("the sample desk: twenty leads, some red, some mid-cadence; only an admin restarts it", () => { | |
| 229 | test("the sample desk: twenty-one leads, some red, some mid-cadence; only an admin restarts it", () => { | |
| 230 | 230 | const sampleDb = openDb(mkdtempSync(join(tmpdir(), "app-sample-"))); |
| 231 | 231 | expect(resetSample(sampleDb, alice).ok).toBe(false); |
| 232 | expect(must(resetSample(sampleDb, root, T0)).leads).toBe(20); | |
| 232 | expect(must(resetSample(sampleDb, root, T0)).leads).toBe(21); | |
| 233 | 233 | const listed = must(listLeads(sampleDb, alice, { now: T0.getTime() })); |
| 234 | 234 | // A believable first record: 14 leads in 30 days, 57% answered or waiting under 5 minutes. |
| 235 | 235 | expect(listed.record).toMatchObject({ leads: 14, replied: 7, within_5_min_pct: 57 }); |
| @@ -260,7 +260,8 @@ test("the desk's views: each lists exactly its leads, counts add up, a bad value | ||
| 260 | 260 | const show = (v: unknown) => must(listLeads(sampleDb, alice, { ...at, show: v })); |
| 261 | 261 | const needs = show("needs"), open = show("open"), closed = show("closed"); |
| 262 | 262 | const ids = (d: { leads: { id: number }[] }) => d.leads.map((l) => l.id); |
| 263 | expect(needs.leads.every((l) => l.ready !== null && !["qualified", "lost"].includes(l.state))).toBe(true); | |
| 263 | // A past client's anniversary check-in is the one thing a closed lead can have waiting. | |
| 264 | expect(needs.leads.every((l) => l.ready !== null && (!["qualified", "lost"].includes(l.state) || l.ready.day > 100 && l.ready.day < 200))).toBe(true); | |
| 264 | 265 | expect(open.leads.every((l) => l.ready === null && !["qualified", "lost"].includes(l.state))).toBe(true); |
| 265 | 266 | expect(closed.leads.every((l) => ["qualified", "lost"].includes(l.state))).toBe(true); |
| 266 | 267 | expect(needs.leads.length).toBeGreaterThan(0); |
| @@ -269,7 +270,7 @@ test("the desk's views: each lists exactly its leads, counts add up, a bad value | ||
| 269 | 270 | // A lead put away until a date is in none of the three, only in all. |
| 270 | 271 | const away = everyone.filter((l) => l.back_on !== null); |
| 271 | 272 | expect(away.length).toBe(1); |
| 272 | expect([...ids(needs), ...ids(open), ...ids(closed), ...ids({ leads: away })].sort()).toEqual(ids({ leads: everyone }).sort()); | |
| 273 | expect([...new Set([...ids(needs), ...ids(open), ...ids(closed), ...ids({ leads: away })])].sort()).toEqual(ids({ leads: everyone }).sort()); | |
| 273 | 274 | expect(needs.counts).toEqual({ |
| 274 | 275 | needs: needs.leads.length, |
| 275 | 276 | open: open.leads.length, |
src/actions.ts +22 −5
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { REASON_HELP, lostCounts, reasonOf, type LostCount } from "./lost.ts"; |
| 11 | import { KIND_HELP, closedCounts, closingOf, kindOf, type DealCount } from "./closed.ts"; | |
| 11 | 12 | import { csvName, leadsCsv } from "./export.ts"; |
| 12 | 13 | import { mergeDetail, record, type Act, type AuditRow } from "./audit.ts"; |
| 13 | 14 | import { MAX_NOTE, outcomeOf } from "./calls.ts"; |
| @@ -29,6 +30,7 @@ import { | ||
| 29 | 30 | advance, |
| 30 | 31 | clearLeads, |
| 31 | 32 | CLOSED, |
| 33 | anniversaries, | |
| 32 | 34 | close, |
| 33 | 35 | countViews, |
| 34 | 36 | desk, |
| @@ -1400,7 +1402,7 @@ function restoring( | ||
| 1400 | 1402 | export const setState = ( |
| 1401 | 1403 | db: Database, |
| 1402 | 1404 | who: Who, |
| 1403 | input: { id: number; state: unknown; reason?: unknown }, | |
| 1405 | input: { id: number; state: unknown; reason?: unknown; kind?: unknown; closed_on?: unknown }, | |
| 1404 | 1406 | at?: Date, |
| 1405 | 1407 | ): Result<LeadDetail> => |
| 1406 | 1408 | audited( |
| @@ -1410,17 +1412,20 @@ export const setState = ( | ||
| 1410 | 1412 | "update", |
| 1411 | 1413 | input.id, |
| 1412 | 1414 | mark(db, who, input, at), |
| 1413 | input.state === "qualified" || input.state === "lost" | |
| 1415 | input.state === "qualified" || input.state === "lost" || input.state === "closed" | |
| 1414 | 1416 | ? input.state |
| 1415 | 1417 | : input.state === "open" |
| 1416 | 1418 | ? "reopened" |
| 1417 | 1419 | : undefined, |
| 1420 | input.state === "closed" && typeof input.closed_on === "string" | |
| 1421 | ? JSON.stringify({ kind: kindOf(input.kind) ?? null, on: input.closed_on.trim().slice(0, 10) }) | |
| 1422 | : undefined, | |
| 1418 | 1423 | ); |
| 1419 | 1424 | |
| 1420 | 1425 | function mark( |
| 1421 | 1426 | db: Database, |
| 1422 | 1427 | who: Who, |
| 1423 | input: { id: number; state: unknown; reason?: unknown }, | |
| 1428 | input: { id: number; state: unknown; reason?: unknown; kind?: unknown; closed_on?: unknown }, | |
| 1424 | 1429 | at?: Date, |
| 1425 | 1430 | ): Result<LeadDetail> { |
| 1426 | 1431 | const found = leadFor(db, who, "update", input.id, { |
| @@ -1435,8 +1440,17 @@ function mark( | ||
| 1435 | 1440 | emit("lead.changed", { id: found.value.id }); |
| 1436 | 1441 | return ok(detail(db, found.value.id)!); |
| 1437 | 1442 | } |
| 1443 | if (input.state === "closed") { | |
| 1444 | const kind = kindOf(input.kind); | |
| 1445 | if (kind === undefined) return fail("invalid", `kind: ${KIND_HELP}`); | |
| 1446 | const day = closingOf(input.closed_on, (at ?? new Date()).getTime(), currentAgent(db).timeZone); | |
| 1447 | if ("error" in day) return fail("invalid", `closed_on: ${day.error}`); | |
| 1448 | close(db, found.value.id, { state: "qualified", deal: { kind, on: day.on } }, stamp(at)); | |
| 1449 | emit("lead.changed", { id: found.value.id }); | |
| 1450 | return ok(detail(db, found.value.id)!); | |
| 1451 | } | |
| 1438 | 1452 | if (input.state !== "qualified" && input.state !== "lost") |
| 1439 | return fail("invalid", "A lead is marked qualified or lost, or put back as open."); | |
| 1453 | return fail("invalid", "A lead is marked qualified, lost or closed (bought or sold), or put back as open."); | |
| 1440 | 1454 | const reason = reasonOf(input.reason); |
| 1441 | 1455 | if (input.state === "lost" && reason === undefined) return fail("invalid", REASON_HELP); |
| 1442 | 1456 | close( |
| @@ -1603,7 +1617,7 @@ export function tick( | ||
| 1603 | 1617 | export function turn(db: Database, at?: Date): number[] { |
| 1604 | 1618 | const now = stamp(at); |
| 1605 | 1619 | const sent = [...sendHeld(db, now, currentAgent(db)), ...sendHeldThanks(db, now, currentAgent(db))]; |
| 1606 | const leads = [...new Set([...advance(db, now), ...sent])]; | |
| 1620 | const leads = [...new Set([...advance(db, now), ...anniversaries(db, Date.parse(now), currentAgent(db)), ...sent])]; | |
| 1607 | 1621 | for (const id of leads) emit("lead.changed", { id }); |
| 1608 | 1622 | return leads; |
| 1609 | 1623 | } |
| @@ -1953,6 +1967,8 @@ export interface Activity { | ||
| 1953 | 1967 | readonly older: number | undefined; |
| 1954 | 1968 | /** Leads lost in the last 30 days, by reason. */ |
| 1955 | 1969 | readonly lost: readonly LostCount[]; |
| 1970 | /** Deals closed in the last 30 days, by kind. */ | |
| 1971 | readonly deals: readonly DealCount[]; | |
| 1956 | 1972 | } |
| 1957 | 1973 | |
| 1958 | 1974 | /** One page of the audit trail with the leads' names looked up, for the Activity page. */ |
| @@ -1986,6 +2002,7 @@ export function activity( | ||
| 1986 | 2002 | older: |
| 1987 | 2003 | found.value.length > ACTIVITY_PAGE ? rows[rows.length - 1]!.id : undefined, |
| 1988 | 2004 | lost: lostCounts(db, Date.now()), |
| 2005 | deals: closedCounts(db, Date.now()), | |
| 1989 | 2006 | }); |
| 1990 | 2007 | } |
| 1991 | 2008 |
src/audit.ts +2 −2
| @@ -34,8 +34,8 @@ export interface AuditRow { | ||
| 34 | 34 | readonly detail: string | null; |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | /** Messages sent are `reply` and `first_reply`; a close is `qualified` or `lost`; `reopened` puts it back; `opted_out` is a lead who asked to stop texts; `inbound` is what the lead wrote back, pasted in; `call` is a phone call the agent logged; `showing` books or moves a showing, `showing_off` cancels it; `edited` is a correction of the lead's name, email or phone; `held` is a text approved outside quiet hours, to go at the next 8:00 a.m.; `unhold` takes back a text held for quiet hours; `noted` is a change to the agent's private note on the lead; `merged` is a new arrival who was already a lead (the detail names the arrival's source and the lead): their message joined that lead; `openers` is a change to how first replies open (the profile); `cadence` is a change to the follow-up days (the profile); `skipped` sets one upcoming follow-up aside by hand, `unskipped` brings it back; `put_away` parks a lead until a date (the detail holds `{"on":"YYYY-MM-DD"}`), `brought_back` clears that date; `starred` keeps a lead close, `unstarred` lets it go; `unsent` takes back a send within its minute; `referred` is a lead added with the person who sent them (the detail holds `{"referrer":"name"}`); `thanked` approves the thank-you to a referrer, `thanks_held` approves it as a text held for quiet hours, `thanks_skipped` sets it aside; `price_change` is a listing's new price drafting a note to each lead who asked about it (the detail holds `{"address","price","drafted"}`); `imported` is a lead brought in from a spreadsheet (no message drafted, no clock). */ | |
| 38 | export const ACTS = ["reply", "first_reply", "qualified", "lost", "reopened", "opted_out", "inbound", "call", "showing", "showing_off", "edited", "held", "unhold", "openers", "cadence", "replies", "noted", "merged", "skipped", "unskipped", "put_away", "brought_back", "starred", "unstarred", "unsent", "referred", "thanked", "thanks_held", "thanks_skipped", "price_change", "imported"] as const; | |
| 37 | /** Messages sent are `reply` and `first_reply`; a close is `qualified` or `lost`; `reopened` puts it back; `opted_out` is a lead who asked to stop texts; `inbound` is what the lead wrote back, pasted in; `call` is a phone call the agent logged; `showing` books or moves a showing, `showing_off` cancels it; `edited` is a correction of the lead's name, email or phone; `held` is a text approved outside quiet hours, to go at the next 8:00 a.m.; `unhold` takes back a text held for quiet hours; `noted` is a change to the agent's private note on the lead; `merged` is a new arrival who was already a lead (the detail names the arrival's source and the lead): their message joined that lead; `openers` is a change to how first replies open (the profile); `cadence` is a change to the follow-up days (the profile); `skipped` sets one upcoming follow-up aside by hand, `unskipped` brings it back; `put_away` parks a lead until a date (the detail holds `{"on":"YYYY-MM-DD"}`), `brought_back` clears that date; `starred` keeps a lead close, `unstarred` lets it go; `unsent` takes back a send within its minute; `referred` is a lead added with the person who sent them (the detail holds `{"referrer":"name"}`); `thanked` approves the thank-you to a referrer, `thanks_held` approves it as a text held for quiet hours, `thanks_skipped` sets it aside; `price_change` is a listing's new price drafting a note to each lead who asked about it (the detail holds `{"address","price","drafted"}`); `imported` is a lead brought in from a spreadsheet (no message drafted, no clock); `closed` is a deal closed, bought or sold, on a date (the detail holds `{"kind","on"}`). */ | |
| 38 | export const ACTS = ["reply", "first_reply", "qualified", "lost", "reopened", "opted_out", "inbound", "call", "showing", "showing_off", "edited", "held", "unhold", "openers", "cadence", "replies", "noted", "merged", "skipped", "unskipped", "put_away", "brought_back", "starred", "unstarred", "unsent", "referred", "thanked", "thanks_held", "thanks_skipped", "price_change", "imported", "closed"] as const; | |
| 39 | 39 | export type Act = (typeof ACTS)[number]; |
| 40 | 40 | |
| 41 | 41 | /** What a `merged` row's detail holds: where the new arrival came from, and the lead they already were. */ |
src/closed.test.ts added +294
| @@ -0,0 +1,294 @@ | ||
| 1 | /** | |
| 2 | * A deal closed: bought or sold on a date, undone like any close, and a year | |
| 3 | * on a home-anniversary check-in waits on the desk, once per year. | |
| 4 | */ | |
| 5 | import { Database } from "bun:sqlite"; | |
| 6 | import { afterAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtempSync } from "node:fs"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { addLead, getLead, listAudit, listLeads, sendReply, setState, turn, type Result } from "./actions.ts"; | |
| 11 | import { anniversaryOf, closedCounts, closingOf } from "./closed.ts"; | |
| 12 | import { migrate, migrations, openDb } from "./db.ts"; | |
| 13 | import { ANNIVERSARY_DAY } from "./drafts.ts"; | |
| 14 | import { leadsCsv } from "./export.ts"; | |
| 15 | import { desk } from "./leads.ts"; | |
| 16 | import { rpc } from "./mcp.ts"; | |
| 17 | import { activityPage } from "./pages/activity.ts"; | |
| 18 | import { currentAgent, storeAgent } from "./profile.ts"; | |
| 19 | import { seed } from "./sample.ts"; | |
| 20 | import { plain, terminal } from "./terminal.ts"; | |
| 21 | import { serve } from "./server.ts"; | |
| 22 | import { sign, type Who } from "./who.ts"; | |
| 23 | ||
| 24 | const alice: Who = { role: "user", id: "u1", name: "alice" }; | |
| 25 | const anon: Who = { role: "anon" }; | |
| 26 | const T0 = new Date("2026-03-01T10:00:00Z"); | |
| 27 | const DAY = 86_400_000; | |
| 28 | const at = (days: number) => new Date(T0.getTime() + days * DAY); | |
| 29 | ||
| 30 | const fresh = (zone = "UTC") => { | |
| 31 | const db = openDb(mkdtempSync(join(tmpdir(), "app-closed-"))); | |
| 32 | storeAgent(db, { ...currentAgent(db), timeZone: zone }, "test", T0.toISOString()); | |
| 33 | return db; | |
| 34 | }; | |
| 35 | const must = <T>(r: Result<T>): T => { | |
| 36 | if (!r.ok) throw new Error(r.message); | |
| 37 | return r.value; | |
| 38 | }; | |
| 39 | const answered = (db: ReturnType<typeof fresh>, consent = false) => { | |
| 40 | const id = must( | |
| 41 | addLead(db, alice, { name: "Dana Reyes", email: "[email protected]", phone: "(206) 555-0111", source: "zillow", message: "Can I see 12 Oak Street?", consent }, T0), | |
| 42 | ).id; | |
| 43 | must(sendReply(db, alice, { id }, T0)); | |
| 44 | return id; | |
| 45 | }; | |
| 46 | const lead = (db: ReturnType<typeof fresh>, id: number) => must(getLead(db, alice, { id })); | |
| 47 | const yearly = (db: ReturnType<typeof fresh>, id: number) => | |
| 48 | lead(db, id).messages.filter((m) => m.day > ANNIVERSARY_DAY && m.day < 200); | |
| 49 | const close = (db: ReturnType<typeof fresh>, id: number, over: Record<string, unknown> = {}) => | |
| 50 | setState(db, alice, { id, state: "closed", kind: "bought", closed_on: "2026-03-01", ...over }, T0); | |
| 51 | ||
| 52 | test("the closing date is a real day, from 1990 to a year ahead, on the agent's calendar", () => { | |
| 53 | const now = T0.getTime(); | |
| 54 | expect(closingOf("2026-03-01", now, "UTC")).toEqual({ on: "2026-03-01" }); | |
| 55 | expect(closingOf("2027-03-01", now, "UTC")).toEqual({ on: "2027-03-01" }); | |
| 56 | expect(closingOf("1995-06-01", now, "UTC")).toEqual({ on: "1995-06-01" }); | |
| 57 | for (const bad of ["2027-03-02", "2026-02-30", "1989-12-31", "2026-3-5", "soon", "", 20260305, null, {}, "2026-03-05T10:00"]) | |
| 58 | expect(closingOf(bad, now, "UTC")).toHaveProperty("error"); | |
| 59 | // 23:30 on the 1st in Los Angeles is already the 2nd in UTC: the year ahead is counted where the agent is. | |
| 60 | const late = Date.parse("2026-03-02T07:30:00Z"); | |
| 61 | expect(closingOf("2027-03-01", late, "America/Los_Angeles")).toEqual({ on: "2027-03-01" }); | |
| 62 | expect(closingOf("2027-03-02", late, "America/Los_Angeles")).toHaveProperty("error"); | |
| 63 | }); | |
| 64 | ||
| 65 | test("an anniversary is the same month and day in a later year; a leap day keeps to the 28th", () => { | |
| 66 | expect(anniversaryOf("2026-03-01", "2027-03-01")).toBe(1); | |
| 67 | expect(anniversaryOf("2026-03-01", "2029-03-01")).toBe(3); | |
| 68 | expect(anniversaryOf("2026-03-01", "2026-03-01")).toBeNull(); | |
| 69 | expect(anniversaryOf("2026-03-01", "2027-03-02")).toBeNull(); | |
| 70 | expect(anniversaryOf("2028-02-29", "2029-02-28")).toBe(1); | |
| 71 | expect(anniversaryOf("2028-02-29", "2032-02-29")).toBe(4); | |
| 72 | expect(anniversaryOf("2028-02-29", "2032-02-28")).toBeNull(); | |
| 73 | }); | |
| 74 | ||
| 75 | test("marking a lead closed stops its follow-ups, keeps kind and date, and is audited; undoing it is the re-open", () => { | |
| 76 | const db = fresh(); | |
| 77 | const id = answered(db); | |
| 78 | const closed = must(close(db, id, { kind: "sold", closed_on: "2026-02-20" })); | |
| 79 | expect(closed).toMatchObject({ state: "qualified", closedKind: "sold", closedOn: "2026-02-20" }); | |
| 80 | expect(closed.messages.filter((m) => m.day > 0).every((m) => m.status === "skipped")).toBe(true); | |
| 81 | const row = must(listAudit(db, { role: "admin", id: "a", name: "root" })).find((a) => a.act === "closed")!; | |
| 82 | expect(JSON.parse(row.detail!)).toEqual({ kind: "sold", on: "2026-02-20" }); | |
| 83 | const back = must(setState(db, alice, { id, state: "open" }, at(1))); | |
| 84 | expect(back).toMatchObject({ state: "contacted", closedKind: null, closedOn: null }); | |
| 85 | expect(back.messages.filter((m) => m.day > 0).every((m) => m.status === "scheduled")).toBe(true); | |
| 86 | // Marking it plain qualified afterwards leaves no deal behind. | |
| 87 | must(close(db, id)); | |
| 88 | expect(must(setState(db, alice, { id, state: "qualified" }, at(2)))).toMatchObject({ closedKind: null, closedOn: null }); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a bad kind or date is a 422 naming the field, and changes nothing; an anonymous visitor may not", () => { | |
| 92 | const db = fresh(); | |
| 93 | const id = answered(db); | |
| 94 | for (const [over, field] of [ | |
| 95 | [{ kind: "rented" }, "kind"], | |
| 96 | [{ kind: undefined }, "kind"], | |
| 97 | [{ closed_on: "2027-03-02" }, "closed_on"], | |
| 98 | [{ closed_on: "2026-02-30" }, "closed_on"], | |
| 99 | [{ closed_on: undefined }, "closed_on"], | |
| 100 | ] as const) { | |
| 101 | const r = close(db, id, over); | |
| 102 | expect(r.ok).toBe(false); | |
| 103 | if (!r.ok) { | |
| 104 | expect(r.error).toBe("invalid"); | |
| 105 | expect(r.message).toContain(field); | |
| 106 | } | |
| 107 | } | |
| 108 | expect(lead(db, id).state).toBe("contacted"); | |
| 109 | expect(setState(db, anon, { id, state: "closed", kind: "bought", closed_on: "2026-03-01" }, T0).ok).toBe(false); | |
| 110 | expect(lead(db, id).closedKind).toBeNull(); | |
| 111 | }); | |
| 112 | ||
| 113 | test("on the anniversary a check-in naming the address waits as a draft; it is there once a year, whatever the clock does", () => { | |
| 114 | const db = fresh(); | |
| 115 | const id = answered(db); | |
| 116 | must(close(db, id, { closed_on: "2026-02-28" })); | |
| 117 | // Before the day, on the day after, and in the closing year: nothing. | |
| 118 | turn(db, T0); | |
| 119 | turn(db, new Date("2027-03-01T09:00:00Z")); | |
| 120 | expect(yearly(db, id)).toEqual([]); | |
| 121 | turn(db, new Date("2027-02-28T09:00:00Z")); | |
| 122 | const first = yearly(db, id); | |
| 123 | expect(first).toHaveLength(1); | |
| 124 | expect(first[0]).toMatchObject({ day: ANNIVERSARY_DAY + 1, status: "draft", channel: "email" }); | |
| 125 | expect(first[0]!.body).toContain("happy home anniversary"); | |
| 126 | expect(first[0]!.body).toContain("a year"); | |
| 127 | // Ticking again the same day, and later in the day, drafts nothing more. | |
| 128 | turn(db, new Date("2027-02-28T09:00:00Z")); | |
| 129 | turn(db, new Date("2027-02-28T22:00:00Z")); | |
| 130 | expect(yearly(db, id)).toHaveLength(1); | |
| 131 | // Nothing went: it waits on the desk, in Needs you, though the lead is closed. | |
| 132 | expect(lead(db, id).messages.find((m) => m.day === ANNIVERSARY_DAY + 1)!.sent_at).toBeNull(); | |
| 133 | const needs = must(listLeads(db, alice, { show: "needs", now: Date.parse("2027-02-28T09:00:00Z") })).leads; | |
| 134 | expect(needs.map((l) => l.id)).toContain(id); | |
| 135 | const shut = must(listLeads(db, alice, { show: "closed", now: Date.parse("2027-02-28T09:00:00Z") })).leads; | |
| 136 | expect(shut.map((l) => l.id)).toContain(id); | |
| 137 | // Approved, it goes; the same year never drafts a second. | |
| 138 | must(sendReply(db, alice, { id }, new Date("2027-02-28T10:00:00Z"))); | |
| 139 | turn(db, new Date("2027-02-28T11:00:00Z")); | |
| 140 | expect(yearly(db, id).map((m) => m.status)).toEqual(["sent"]); | |
| 141 | // The next year, the second. | |
| 142 | turn(db, new Date("2028-02-28T09:00:00Z")); | |
| 143 | expect(yearly(db, id).map((m) => [m.day, m.status])).toEqual([[101, "sent"], [102, "draft"]]); | |
| 144 | expect(yearly(db, id)[1]!.body).toContain("2 years"); | |
| 145 | }); | |
| 146 | ||
| 147 | test("an unsent anniversary is set aside by the next one, and reopening the lead takes it away", () => { | |
| 148 | const db = fresh(); | |
| 149 | const id = answered(db); | |
| 150 | must(close(db, id, { closed_on: "2026-02-28" })); | |
| 151 | turn(db, new Date("2027-02-28T09:00:00Z")); | |
| 152 | turn(db, new Date("2028-02-28T09:00:00Z")); | |
| 153 | expect(yearly(db, id).map((m) => m.status)).toEqual(["skipped", "draft"]); | |
| 154 | must(setState(db, alice, { id, state: "open" }, new Date("2028-03-01T09:00:00Z"))); | |
| 155 | expect(yearly(db, id)).toEqual([]); | |
| 156 | }); | |
| 157 | ||
| 158 | test("the address comes from the lead's showing; a text goes only to a lead who agreed to texts", () => { | |
| 159 | const db = fresh(); | |
| 160 | const id = answered(db, true); | |
| 161 | db.query("INSERT INTO showing (lead_id, at, address, hold_until, booked_at) VALUES (?1, '2026-02-01T10:00:00Z', '12 Oak Street', '2026-02-02T00:00:00Z', '2026-01-30T10:00:00Z')").run(id); | |
| 162 | must(close(db, id, { closed_on: "2026-02-28" })); | |
| 163 | turn(db, new Date("2027-02-28T09:00:00Z")); | |
| 164 | const [m] = yearly(db, id); | |
| 165 | expect(m!.channel).toBe("text"); | |
| 166 | expect(m!.body).toContain("12 Oak Street"); | |
| 167 | expect(m!.body.startsWith("Hi Dana, happy home anniversary")).toBe(true); | |
| 168 | }); | |
| 169 | ||
| 170 | test("the anniversary is the agent's day, not the server's", () => { | |
| 171 | const db = fresh("Pacific/Auckland"); | |
| 172 | const id = answered(db); | |
| 173 | must(close(db, id, { closed_on: "2026-03-01" })); | |
| 174 | // 2027-03-01 11:00 UTC is already the 2nd in Auckland (UTC+13): not the day; 2027-02-28 12:00 UTC is the 1st there. | |
| 175 | turn(db, new Date("2027-03-01T11:00:00Z")); | |
| 176 | expect(yearly(db, id)).toEqual([]); | |
| 177 | turn(db, new Date("2027-02-28T12:00:00Z")); | |
| 178 | expect(yearly(db, id)).toHaveLength(1); | |
| 179 | }); | |
| 180 | ||
| 181 | test("the desk counts deals closed in 30 days by kind, and the export carries kind and date", () => { | |
| 182 | const db = fresh(); | |
| 183 | const a = answered(db); | |
| 184 | const b = must(addLead(db, alice, { name: "Eli Park", email: "[email protected]", phone: "(206) 555-0112", source: "website", message: "Sell?", consent: false }, T0)).id; | |
| 185 | must(sendReply(db, alice, { id: b }, T0)); | |
| 186 | must(setState(db, alice, { id: a, state: "closed", kind: "bought", closed_on: "2026-03-01" }, T0)); | |
| 187 | must(setState(db, alice, { id: b, state: "closed", kind: "bought", closed_on: "2026-02-27" }, T0)); | |
| 188 | expect(closedCounts(db, T0.getTime())).toEqual([{ kind: "bought", count: 2 }]); | |
| 189 | expect(closedCounts(db, at(40).getTime())).toEqual([]); | |
| 190 | const html = activityPage({ who: alice, agent: currentAgent(db), rows: [], names: new Map(), older: undefined, lost: [], deals: closedCounts(db, T0.getTime()), sentOnly: false }); | |
| 191 | expect(html).toContain("Closed in the last 30 days"); | |
| 192 | expect(html).toMatch(/<strong>2<\/strong> bought/); | |
| 193 | const csv = leadsCsv(desk(db, 10), new Map()).split("\r\n"); | |
| 194 | expect(csv[0]!.endsWith(",closed,closed on")).toBe(true); | |
| 195 | expect(csv.some((row) => row.endsWith(",bought,2026-03-01"))).toBe(true); | |
| 196 | }); | |
| 197 | ||
| 198 | test("the MCP and the terminal close a deal through the same action", () => { | |
| 199 | const db = fresh(); | |
| 200 | const id = answered(db); | |
| 201 | const called = rpc(db, alice, { | |
| 202 | jsonrpc: "2.0", | |
| 203 | id: 1, | |
| 204 | method: "tools/call", | |
| 205 | params: { name: "mark_lead", arguments: { id, state: "closed", kind: "sold", closed_on: "2026-02-01" } }, | |
| 206 | }); | |
| 207 | expect(JSON.stringify(called.body)).toContain("closedKind"); | |
| 208 | expect(lead(db, id)).toMatchObject({ closedKind: "sold", closedOn: "2026-02-01" }); | |
| 209 | const refused = rpc(db, alice, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "mark_lead", arguments: { id, state: "closed", kind: "sold", closed_on: "2030-01-01" } } }); | |
| 210 | expect(JSON.stringify(refused.body)).toContain("closed_on"); | |
| 211 | ||
| 212 | const db2 = fresh(); | |
| 213 | const other = answered(db2); | |
| 214 | const t = terminal(db2, alice); | |
| 215 | const read = () => t.screen(100).map(plain).join("\n"); | |
| 216 | t.key("\r"); | |
| 217 | expect(read()).toContain("y closed: bought or sold"); | |
| 218 | t.key("y"); | |
| 219 | t.key("2"); | |
| 220 | for (const ch of "2030-01-01") t.key(ch); | |
| 221 | t.key("\r"); | |
| 222 | expect(read()).toContain("closed_on:"); | |
| 223 | for (let i = 0; i < 10; i++) t.key("\x7f"); | |
| 224 | for (const ch of "2026-02-01") t.key(ch); | |
| 225 | t.key("\r"); | |
| 226 | expect(read()).toContain("Closed — sold, Feb 1, 2026"); | |
| 227 | expect(lead(db2, other)).toMatchObject({ closedKind: "sold", closedOn: "2026-02-01" }); | |
| 228 | }); | |
| 229 | ||
| 230 | test("a database from before the closed columns keeps its leads and gains them empty", () => { | |
| 231 | const db = new Database(":memory:"); | |
| 232 | const at = migrations.findIndex((m) => m.includes("ADD COLUMN closed_kind")); | |
| 233 | expect(at).toBeGreaterThan(0); | |
| 234 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"); | |
| 235 | migrations.slice(0, at).forEach((sql, i) => { | |
| 236 | try { | |
| 237 | db.exec(sql); | |
| 238 | } catch (e) { | |
| 239 | if (!/duplicate column/.test(String(e))) throw e; | |
| 240 | } | |
| 241 | db.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1); | |
| 242 | }); | |
| 243 | db.exec("INSERT INTO lead (id, name, email, source, message, consent, state) VALUES (7, 'Old Lead', '[email protected]', 'zillow', 'Is it open?', 1, 'qualified')"); | |
| 244 | migrate(db); | |
| 245 | expect(db.query("SELECT state, closed_kind, closed_on FROM lead WHERE id = 7").get()).toEqual({ state: "qualified", closed_kind: null, closed_on: null }); | |
| 246 | expect(() => db.exec("UPDATE lead SET closed_kind = 'rented' WHERE id = 7")).toThrow(); | |
| 247 | }); | |
| 248 | ||
| 249 | test("the sample desk has a past client whose anniversary is today, with the draft waiting", () => { | |
| 250 | const db = fresh(); | |
| 251 | seed(db, T0); | |
| 252 | const tom = desk(db, 50).find((l) => l.name === "Tom Whitfield")!; | |
| 253 | expect(tom).toMatchObject({ state: "qualified", closedKind: "bought" }); | |
| 254 | expect(tom.closedOn).toBe("2025-03-01"); | |
| 255 | expect(tom.ready).toMatchObject({ day: ANNIVERSARY_DAY + 1 }); | |
| 256 | expect(lead(db, tom.id).messages.find((m) => m.day === ANNIVERSARY_DAY + 1)!.body).toContain("14 Lakeview Ct"); | |
| 257 | // Another tick the same day does not draft a second for the same year. | |
| 258 | turn(db, T0); | |
| 259 | expect(yearly(db, tom.id)).toHaveLength(1); | |
| 260 | }); | |
| 261 | ||
| 262 | const KEY = "a-key-only-the-forge-holds"; | |
| 263 | const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-closed-http-")), identityKey: KEY }); | |
| 264 | const base = `http://${server.hostname}:${server.port}`; | |
| 265 | afterAll(() => server.stop(true)); | |
| 266 | const signed = (() => { | |
| 267 | const identity = `u-http|alice|user|${Date.now() + 600_000}`; | |
| 268 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 269 | })(); | |
| 270 | const JSON_HEADERS = { "content-type": "application/json", accept: "application/json", ...signed }; | |
| 271 | ||
| 272 | test("the API closes a deal with PATCH and POST /state, answers 422 naming the field, and the page has the form", async () => { | |
| 273 | const made = (await ( | |
| 274 | await fetch(`${base}/leads`, { | |
| 275 | method: "POST", | |
| 276 | headers: JSON_HEADERS, | |
| 277 | body: JSON.stringify({ name: "Dana Reyes", email: "[email protected]", phone: "(206) 555-0111", source: "website", message: "Can I see 12 Oak St?", consent: false }), | |
| 278 | }) | |
| 279 | ).json()) as { id: number }; | |
| 280 | const patch = (body: unknown) => fetch(`${base}/leads/${made.id}`, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }); | |
| 281 | const bad = await patch({ state: "closed", kind: "bought", closed_on: "2999-01-01" }); | |
| 282 | expect(bad.status).toBe(422); | |
| 283 | expect(JSON.stringify(await bad.json())).toContain("closed_on"); | |
| 284 | expect((await patch({ state: "closed", kind: "leased", closed_on: "2026-01-01" })).status).toBe(422); | |
| 285 | const page = await (await fetch(`${base}/leads/${made.id}`, { headers: signed })).text(); | |
| 286 | expect(page).toContain('name="closed_on"'); | |
| 287 | const ok = await patch({ state: "closed", kind: "bought", closed_on: "2026-01-01" }); | |
| 288 | expect(ok.status).toBe(200); | |
| 289 | expect(await ok.json()).toMatchObject({ state: "qualified", closedKind: "bought", closedOn: "2026-01-01" }); | |
| 290 | const undone = await fetch(`${base}/leads/${made.id}/state`, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify({ state: "open" }) }); | |
| 291 | expect(await undone.json()).toMatchObject({ closedKind: null, closedOn: null }); | |
| 292 | const spec = await (await fetch(`${base}/openapi.json`, { headers: signed })).text(); | |
| 293 | expect(spec).toContain("closed_on"); | |
| 294 | }); |
src/closed.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * A deal closed: the lead bought or sold with the agent, on a date. The | |
| 3 | * lead is `qualified` as far as the cadence goes (nothing more is | |
| 4 | * scheduled); the kind and the date are what make it a past client, and each | |
| 5 | * year on the closing date's anniversary (the agent's calendar) one short | |
| 6 | * check-in waits as a draft. Plain data in, plain data out. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { firstName } from "./drafts.ts"; | |
| 11 | import { today } from "./putaway.ts"; | |
| 12 | ||
| 13 | export const CLOSED_KINDS = ["bought", "sold"] as const; | |
| 14 | export type ClosedKind = (typeof CLOSED_KINDS)[number]; | |
| 15 | ||
| 16 | export const KIND_LABEL: Readonly<Record<ClosedKind, string>> = { bought: "bought", sold: "sold" }; | |
| 17 | ||
| 18 | /** What a door tells whoever sent a kind that is not on the list. */ | |
| 19 | export const KIND_HELP = `A closed deal is one of: ${CLOSED_KINDS.join(", ")}.`; | |
| 20 | ||
| 21 | /** The kind from user text, or undefined when it is not on the list. */ | |
| 22 | export const kindOf = (value: unknown): ClosedKind | undefined => CLOSED_KINDS.find((k) => k === value); | |
| 23 | ||
| 24 | /** The most years a lead's anniversaries are counted, so each stays inside its numbered slots. */ | |
| 25 | export const MAX_YEARS = 99; | |
| 26 | ||
| 27 | const DATE = /^(\d{4})-(\d{2})-(\d{2})$/; | |
| 28 | const DAY_MS = 86_400_000; | |
| 29 | ||
| 30 | /** | |
| 31 | * A closing date from user text: a real `YYYY-MM-DD`, not before 1990 and not | |
| 32 | * more than a year after today on the agent's calendar in `zone`. | |
| 33 | */ | |
| 34 | export function closingOf(text: unknown, now: number, zone: string): { on: string } | { error: string } { | |
| 35 | const bad = { error: "A closing date is a day, like 2026-03-01." }; | |
| 36 | if (typeof text !== "string") return bad; | |
| 37 | const m = DATE.exec(text.trim()); | |
| 38 | if (m === null) return bad; | |
| 39 | const [y, mo, d] = [+m[1]!, +m[2]!, +m[3]!]; | |
| 40 | const real = new Date(Date.UTC(y, mo - 1, d)); | |
| 41 | if (real.getUTCFullYear() !== y || real.getUTCMonth() !== mo - 1 || real.getUTCDate() !== d) return bad; | |
| 42 | const on = text.trim(); | |
| 43 | if (y < 1990) return { error: "Pick a closing date from 1990 on." }; | |
| 44 | const here = today(now, zone); | |
| 45 | const limit = `${+here.slice(0, 4) + 1}${here.slice(4)}`; | |
| 46 | // A leap day a year on is the 28th; compare as plain strings, which sort as dates. | |
| 47 | if (on > (limit.endsWith("-02-29") ? `${limit.slice(0, 5)}02-28` : limit)) | |
| 48 | return { error: "Pick a closing date no more than a year from today." }; | |
| 49 | return { on }; | |
| 50 | } | |
| 51 | ||
| 52 | const isLeap = (y: number): boolean => (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; | |
| 53 | ||
| 54 | /** | |
| 55 | * Which anniversary `here` (a `YYYY-MM-DD`) is of the closing day `on`: 1 for | |
| 56 | * the first, and so on; null when it is not one. A closing on February 29th | |
| 57 | * is kept on the 28th in the years that have none. | |
| 58 | */ | |
| 59 | export function anniversaryOf(on: string, here: string): number | null { | |
| 60 | const years = +here.slice(0, 4) - +on.slice(0, 4); | |
| 61 | if (years < 1 || years > MAX_YEARS) return null; | |
| 62 | const mmdd = on.slice(5); | |
| 63 | const kept = mmdd === "02-29" && !isLeap(+here.slice(0, 4)) ? "02-28" : mmdd; | |
| 64 | return here.slice(5) === kept ? years : null; | |
| 65 | } | |
| 66 | ||
| 67 | /** "Mar 1, 2026" for a closing date, said plainly. */ | |
| 68 | export const closedName = (on: string): string => | |
| 69 | new Date(`${on}T12:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); | |
| 70 | ||
| 71 | /** "Closed — bought" / "Closed — sold", then the date. */ | |
| 72 | export const closedSaid = (lead: { readonly closedKind: ClosedKind | null; readonly closedOn: string | null }): string | null => | |
| 73 | lead.closedKind === null || lead.closedOn === null ? null : `Closed — ${KIND_LABEL[lead.closedKind]}, ${closedName(lead.closedOn)}`; | |
| 74 | ||
| 75 | /** | |
| 76 | * The anniversary check-in: short enough to text, naming the address when it | |
| 77 | * is known, and asking for nothing but a reply. The agent edits it before it | |
| 78 | * goes; it says nothing of the private note. | |
| 79 | */ | |
| 80 | export function anniversaryDraft( | |
| 81 | lead: { readonly name: string; readonly consent: boolean; readonly phone: string }, | |
| 82 | years: number, | |
| 83 | address: string, | |
| 84 | ): string { | |
| 85 | const first = firstName(lead.name); | |
| 86 | const home = address === "" ? "your home" : address; | |
| 87 | const year = years === 1 ? "a year" : `${years} years`; | |
| 88 | const ask = `happy home anniversary! It has been ${year} since we closed on ${home}. How is it treating you? If you ever have questions about the house or the market, I am glad to help.`; | |
| 89 | return lead.consent && lead.phone !== "" ? `Hi ${first}, ${ask}` : `Hi ${first},\n\n${ask}\n\nBest,`; | |
| 90 | } | |
| 91 | ||
| 92 | export interface DealCount { | |
| 93 | readonly kind: ClosedKind; | |
| 94 | readonly count: number; | |
| 95 | } | |
| 96 | ||
| 97 | /** Deals closed (marked) in the 30 days up to `now`, by kind, zeros left out. */ | |
| 98 | export function closedCounts(db: Database, now: number): DealCount[] { | |
| 99 | const found = new Map( | |
| 100 | db | |
| 101 | .query<{ kind: ClosedKind; count: number }, [string]>( | |
| 102 | "SELECT closed_kind AS kind, count(*) AS count FROM lead WHERE state = 'qualified' AND closed_kind IS NOT NULL AND qualified_at >= ?1 GROUP BY closed_kind", | |
| 103 | ) | |
| 104 | .all(new Date(now - 30 * DAY_MS).toISOString()) | |
| 105 | .map((r) => [r.kind, r.count]), | |
| 106 | ); | |
| 107 | return CLOSED_KINDS.map((kind) => ({ kind, count: found.get(kind) ?? 0 })).filter((r) => r.count > 0); | |
| 108 | } |
src/db.ts +5
| @@ -312,6 +312,11 @@ export const migrations: string[] = [ | ||
| 312 | 312 | `UPDATE lead SET qualified_at = updated_at WHERE state = 'qualified'`, |
| 313 | 313 | // A lead brought in from a spreadsheet: it has no inquiry clock and is not a lead in for the record or the report. |
| 314 | 314 | `ALTER TABLE lead ADD COLUMN imported INTEGER NOT NULL DEFAULT 0 CHECK (imported IN (0, 1))`, |
| 315 | // A deal closed: the lead is qualified and bought or sold with the agent on closed_on (YYYY-MM-DD, the agent's calendar). | |
| 316 | // Both are null on every lead that exists today and on any lead not closed as a deal. The state stays 'qualified', so | |
| 317 | // the lead table's CHECK on state needs no rebuild; the kind is checked by the app and by this CHECK. | |
| 318 | `ALTER TABLE lead ADD COLUMN closed_kind TEXT CHECK (closed_kind IN ('bought', 'sold'))`, | |
| 319 | `ALTER TABLE lead ADD COLUMN closed_on TEXT`, | |
| 315 | 320 | ]; |
| 316 | 321 | |
| 317 | 322 | /** |
src/drafts.ts +10 −1
| @@ -67,6 +67,13 @@ export const CHECK_IN_DAY = 300; | ||
| 67 | 67 | * `CHECK_IN_DAY`. It is a draft from the start, never part of the cadence. |
| 68 | 68 | */ |
| 69 | 69 | export const PRICE_CHANGE_DAY = 200; |
| 70 | /** | |
| 71 | * The home-anniversary check-in to a past client is numbered from here (100 + | |
| 72 | * the years since the closing, so 101 is the first), above every cadence day | |
| 73 | * (at most 90) and below `PRICE_CHANGE_DAY`: one per year, never part of the cadence. | |
| 74 | */ | |
| 75 | export const ANNIVERSARY_DAY = 100; | |
| 76 | export const isAnniversary = (day: number): boolean => day > ANNIVERSARY_DAY && day < PRICE_CHANGE_DAY; | |
| 70 | 77 | export const isPriceChange = (day: number): boolean => day >= PRICE_CHANGE_DAY && day < CHECK_IN_DAY; |
| 71 | 78 | export const isCheckIn = (day: number): boolean => day >= CHECK_IN_DAY && day < BEFORE_SHOWING_DAY; |
| 72 | 79 | export const isBeforeShowing = (day: number): boolean => |
| @@ -80,6 +87,8 @@ export const dayLabel = (day: number): string => | ||
| 80 | 87 | ? "First reply" |
| 81 | 88 | : isAnswer(day) |
| 82 | 89 | ? "Answer to their reply" |
| 90 | : isAnniversary(day) | |
| 91 | ? "Home anniversary" | |
| 83 | 92 | : isPriceChange(day) |
| 84 | 93 | ? "Price change" |
| 85 | 94 | : isCheckIn(day) |
| @@ -92,7 +101,7 @@ export const dayLabel = (day: number): string => | ||
| 92 | 101 | |
| 93 | 102 | /** A message's short name in the follow-up plan: "Day 7", "Before the showing". */ |
| 94 | 103 | export const planLabel = (day: number): string => |
| 95 | isPriceChange(day) ? "Price change" : isCheckIn(day) ? "Check-in" : isBeforeShowing(day) ? "Before the showing" : isAfterShowing(day) ? "After the showing" : `Day ${day}`; | |
| 104 | isAnniversary(day) ? "Anniversary" : isPriceChange(day) ? "Price change" : isCheckIn(day) ? "Check-in" : isBeforeShowing(day) ? "Before the showing" : isAfterShowing(day) ? "After the showing" : `Day ${day}`; | |
| 96 | 105 | |
| 97 | 106 | export interface Asker { |
| 98 | 107 | readonly name: string; |
src/export.test.ts +4 −2
| @@ -56,7 +56,7 @@ test("the download has the header, one row per lead, and the file headers", asyn | ||
| 56 | 56 | expect(rows[0]).toEqual([...COLUMNS]); |
| 57 | 57 | const all = await (await get("/leads")).json(); |
| 58 | 58 | expect(rows.length).toBe(1 + all.leads.length); |
| 59 | expect(all.leads.length).toBe(20); | |
| 59 | expect(all.leads.length).toBe(21); | |
| 60 | 60 | expect(rows[1]!.length).toBe(COLUMNS.length); |
| 61 | 61 | expect(["yes", "no"]).toContain(rows[1]![9]!); |
| 62 | 62 | expect(["open", "qualified", "lost"]).toContain(rows[1]![4]!); |
| @@ -174,9 +174,11 @@ test("minutes to first reply, texts allowed, and last message", () => { | ||
| 174 | 174 | "", |
| 175 | 175 | "", |
| 176 | 176 | "", |
| 177 | "", | |
| 178 | "", | |
| 177 | 179 | ]); |
| 178 | 180 | const waiting = parse(leadsCsv([{ ...lead, replied_at: null, consent: false }], new Map()))[1]!; |
| 179 | expect(waiting.slice(6)).toEqual(["", "", "", "no", "", "no", "", "", ""]); | |
| 181 | expect(waiting.slice(6)).toEqual(["", "", "", "no", "", "no", "", "", "", "", ""]); | |
| 180 | 182 | expect(csvName(new Date("2026-11-02T08:00:00Z"))).toBe("leads-2026-11-02.csv"); |
| 181 | 183 | }); |
| 182 | 184 |
src/export.ts +4
| @@ -22,6 +22,8 @@ export const COLUMNS = [ | ||
| 22 | 22 | "referred by", |
| 23 | 23 | "referrer email", |
| 24 | 24 | "referrer phone", |
| 25 | "closed", | |
| 26 | "closed on", | |
| 25 | 27 | ] as const; |
| 26 | 28 | |
| 27 | 29 | /** An international number, "+1 206-555-0101": a plus and then only digits and the marks people type. */ |
| @@ -67,6 +69,8 @@ export function leadsCsv( | ||
| 67 | 69 | lead.referred_by, |
| 68 | 70 | lead.referrer_email, |
| 69 | 71 | lead.referrer_phone, |
| 72 | lead.closedKind ?? "", | |
| 73 | lead.closedOn ?? "", | |
| 70 | 74 | ]); |
| 71 | 75 | return [COLUMNS, ...rows].map((row) => row.map((v, i) => field(v, COLUMNS[i] === "phone" || COLUMNS[i] === "referrer phone")).join(",")).join("\r\n") + "\r\n"; |
| 72 | 76 | } |
src/leads.ts +74 −8
| @@ -17,8 +17,10 @@ import { | ||
| 17 | 17 | afterShowingDraft, |
| 18 | 18 | BEFORE_SHOWING_DAY, |
| 19 | 19 | CHECK_IN_DAY, |
| 20 | ANNIVERSARY_DAY, | |
| 20 | 21 | PRICE_CHANGE_DAY, |
| 21 | 22 | checkInDraft, |
| 23 | isAnniversary, | |
| 22 | 24 | isCheckIn, |
| 23 | 25 | isPriceChange, |
| 24 | 26 | confirmationDraft, |
| @@ -42,8 +44,9 @@ import type { Agent } from "./env.ts"; | ||
| 42 | 44 | import { isQuiet, nextOpening } from "./quiet.ts"; |
| 43 | 45 | import { insertThanks, thanksOf, type Thanks } from "./thanks.ts"; |
| 44 | 46 | import { type LostReason } from "./lost.ts"; |
| 47 | import { anniversaryDraft, anniversaryOf, MAX_YEARS, type ClosedKind } from "./closed.ts"; | |
| 45 | 48 | import { followUpsFinished } from "./finished.ts"; |
| 46 | import { morningOn } from "./putaway.ts"; | |
| 49 | import { morningOn, today } from "./putaway.ts"; | |
| 47 | 50 | import { clockTime, dayName, holdUntil, morningAfter, morningBefore, type Showing } from "./showings.ts"; |
| 48 | 51 | |
| 49 | 52 | export type State = "new" | "contacted" | "qualified" | "lost"; |
| @@ -83,6 +86,10 @@ export interface Lead { | ||
| 83 | 86 | readonly referrer_phone: string; |
| 84 | 87 | /** Brought in from a spreadsheet: no clock, no drafts, and not counted as a lead in (see `insertImported`). */ |
| 85 | 88 | readonly imported: boolean; |
| 89 | /** For a deal closed: bought or sold with the agent; null otherwise. */ | |
| 90 | readonly closedKind: ClosedKind | null; | |
| 91 | /** For a deal closed: the closing date (`YYYY-MM-DD`, the agent's calendar); null otherwise. */ | |
| 92 | readonly closedOn: string | null; | |
| 86 | 93 | } |
| 87 | 94 | |
| 88 | 95 | export interface Message { |
| @@ -173,7 +180,8 @@ export const inView = (lead: DeskLead, view: View): boolean => { | ||
| 173 | 180 | const closed = CLOSED.includes(lead.state); |
| 174 | 181 | switch (view) { |
| 175 | 182 | case "needs": |
| 176 | return !closed && lead.ready !== null; | |
| 183 | // A past client's anniversary check-in waits on the desk though the lead is closed. | |
| 184 | return lead.ready !== null && (!closed || isAnniversary(lead.ready.day)); | |
| 177 | 185 | case "open": |
| 178 | 186 | return !closed && lead.ready === null && lead.back_on === null; |
| 179 | 187 | case "closed": |
| @@ -234,7 +242,9 @@ export type NewLead = { | ||
| 234 | 242 | readonly referrer_phone?: string; |
| 235 | 243 | }; |
| 236 | 244 | |
| 237 | type LeadRecord = Omit<Lead, "consent" | "noteUpdatedAt" | "lostReason" | "lostAt" | "starred" | "imported"> & { | |
| 245 | type LeadRecord = Omit<Lead, "consent" | "noteUpdatedAt" | "lostReason" | "lostAt" | "starred" | "imported" | "closedKind" | "closedOn"> & { | |
| 246 | closed_kind: ClosedKind | null; | |
| 247 | closed_on: string | null; | |
| 238 | 248 | consent: number; |
| 239 | 249 | imported: number; |
| 240 | 250 | starred: number; |
| @@ -244,8 +254,10 @@ type LeadRecord = Omit<Lead, "consent" | "noteUpdatedAt" | "lostReason" | "lostA | ||
| 244 | 254 | /** When it became qualified (see report.ts); not part of a Lead. */ |
| 245 | 255 | qualified_at: string | null; |
| 246 | 256 | }; |
| 247 | const lead = ({ note_updated_at, lost_reason, lost_at, qualified_at: _qualifiedAt, ...row }: LeadRecord): Lead => ({ | |
| 257 | const lead = ({ note_updated_at, lost_reason, lost_at, qualified_at: _qualifiedAt, closed_kind, closed_on, ...row }: LeadRecord): Lead => ({ | |
| 248 | 258 | ...row, |
| 259 | closedKind: closed_kind, | |
| 260 | closedOn: closed_on, | |
| 249 | 261 | consent: row.consent === 1, |
| 250 | 262 | starred: row.starred === 1, |
| 251 | 263 | imported: row.imported === 1, |
| @@ -969,6 +981,7 @@ export function redraftUnsent(db: Database, agent: Agent): void { | ||
| 969 | 981 | |
| 970 | 982 | /** The words for an unsent touch or showing note: the cadence's, or the note for the lead's showing as it stands. */ |
| 971 | 983 | const wordsFor = (db: Database, found: Lead, day: number, agent: Agent): string => { |
| 984 | if (isAnniversary(day)) return anniversaryDraft(found, day - ANNIVERSARY_DAY, showingOf(db, found.id)?.address ?? ""); | |
| 972 | 985 | if (isCheckIn(day)) return checkInDraft(found); |
| 973 | 986 | if (isPriceChange(day)) return priceChangeWords(db, found, day); |
| 974 | 987 | if (!isAfterShowing(day) && !isBeforeShowing(day)) return draft(found, day, agent); |
| @@ -1068,26 +1081,76 @@ function redraftFor(db: Database, lead: Lead, agent: Agent, aside: boolean): voi | ||
| 1068 | 1081 | } |
| 1069 | 1082 | |
| 1070 | 1083 | /** How a lead closes: qualified, or lost, which always comes with its reason. */ |
| 1071 | export type Closing = { readonly state: "qualified" } | { readonly state: "lost"; readonly reason: LostReason }; | |
| 1084 | export type Closing = | |
| 1085 | | { readonly state: "qualified"; readonly deal?: { readonly kind: ClosedKind; readonly on: string } } | |
| 1086 | | { readonly state: "lost"; readonly reason: LostReason }; | |
| 1072 | 1087 | |
| 1073 | 1088 | /** Qualified or lost (with the reason): the lead leaves the cadence, and nothing more is drafted to go. */ |
| 1074 | 1089 | export function close(db: Database, id: number, closing: Closing, at: string): void { |
| 1075 | 1090 | db.transaction(() => { |
| 1076 | 1091 | db.query( |
| 1077 | "UPDATE lead SET state = ?1, updated_at = ?2, lost_reason = ?4, lost_at = ?5, qualified_at = CASE WHEN ?1 = 'qualified' THEN ?2 ELSE qualified_at END, back_on = NULL WHERE id = ?3", | |
| 1092 | "UPDATE lead SET state = ?1, updated_at = ?2, lost_reason = ?4, lost_at = ?5, qualified_at = CASE WHEN ?1 = 'qualified' THEN ?2 ELSE qualified_at END, back_on = NULL, closed_kind = ?6, closed_on = ?7 WHERE id = ?3", | |
| 1078 | 1093 | ).run( |
| 1079 | 1094 | closing.state, |
| 1080 | 1095 | at, |
| 1081 | 1096 | id, |
| 1082 | 1097 | closing.state === "lost" ? closing.reason : null, |
| 1083 | 1098 | closing.state === "lost" ? at : null, |
| 1099 | closing.state === "qualified" ? (closing.deal?.kind ?? null) : null, | |
| 1100 | closing.state === "qualified" ? (closing.deal?.on ?? null) : null, | |
| 1084 | 1101 | ); |
| 1102 | db.query(`DELETE FROM message WHERE lead_id = ?1 AND ${ANNIVERSARIES} AND status IN ${UNSENT_SQL}`).run(id); | |
| 1085 | 1103 | db.query( |
| 1086 | 1104 | "UPDATE message SET status = 'skipped', goes_at = NULL WHERE lead_id = ?1 AND status IN ('draft', 'scheduled', 'due', 'held')", |
| 1087 | 1105 | ).run(id); |
| 1088 | 1106 | })(); |
| 1089 | 1107 | } |
| 1090 | 1108 | |
| 1109 | /** | |
| 1110 | * Each closed deal whose closing date has its anniversary today on the agent's | |
| 1111 | * calendar (`now`, in `zone`) gets one "happy home anniversary" check-in, a | |
| 1112 | * draft waiting for the agent to approve. The message is numbered by the year | |
| 1113 | * (`ANNIVERSARY_DAY` + years), so a year that has one — sent, set aside or | |
| 1114 | * waiting — never gets a second, however often this runs. An older one still | |
| 1115 | * unsent is set aside. A lead who can be reached nowhere gets none. Returns | |
| 1116 | * the ids of the leads it drafted for. | |
| 1117 | */ | |
| 1118 | export function anniversaries(db: Database, now: number, agent: Agent): number[] { | |
| 1119 | const here = today(now, agent.timeZone); | |
| 1120 | const drafted: number[] = []; | |
| 1121 | const deals = db | |
| 1122 | .query<Lead & { closed_on: string }, []>( | |
| 1123 | "SELECT * FROM lead WHERE state = 'qualified' AND closed_kind IS NOT NULL AND closed_on IS NOT NULL", | |
| 1124 | ) | |
| 1125 | .all(); | |
| 1126 | db.transaction(() => { | |
| 1127 | for (const row of deals) { | |
| 1128 | const found = findLead(db, row.id)!; | |
| 1129 | const years = anniversaryOf(row.closed_on, here); | |
| 1130 | if (years === null || years > MAX_YEARS || !reachable(found)) continue; | |
| 1131 | const day = ANNIVERSARY_DAY + years; | |
| 1132 | const had = db.query("SELECT 1 FROM message WHERE lead_id = ?1 AND day = ?2").get(found.id, day); | |
| 1133 | if (had !== null) continue; | |
| 1134 | db.query( | |
| 1135 | `UPDATE message SET status = 'skipped', goes_at = NULL WHERE lead_id = ?1 AND ${ANNIVERSARIES} AND status IN ('draft', 'scheduled', 'due')`, | |
| 1136 | ).run(found.id); | |
| 1137 | const channel = channelFor(found); | |
| 1138 | db.query( | |
| 1139 | "INSERT INTO message (lead_id, day, channel, body, signature, status, due_at) VALUES (?1, ?2, ?3, ?4, ?5, 'draft', ?6)", | |
| 1140 | ).run( | |
| 1141 | found.id, | |
| 1142 | day, | |
| 1143 | channel, | |
| 1144 | anniversaryDraft(found, years, showingOf(db, found.id)?.address ?? ""), | |
| 1145 | signature(channel, agent), | |
| 1146 | new Date(now).toISOString(), | |
| 1147 | ); | |
| 1148 | drafted.push(found.id); | |
| 1149 | } | |
| 1150 | })(); | |
| 1151 | return drafted; | |
| 1152 | } | |
| 1153 | ||
| 1091 | 1154 | /** |
| 1092 | 1155 | * A closed lead goes back in follow-ups: `contacted` if a first reply was |
| 1093 | 1156 | * ever sent, else `new`. Touches never sent are drafted again for `agent` |
| @@ -1098,7 +1161,7 @@ export function reopen(db: Database, found: Lead, at: string, agent: Agent): voi | ||
| 1098 | 1161 | db.transaction(() => { |
| 1099 | 1162 | const replied = found.replied_at !== null; |
| 1100 | 1163 | db.query( |
| 1101 | "UPDATE lead SET state = ?1, updated_at = ?2, lost_reason = NULL, lost_at = NULL, qualified_at = NULL WHERE id = ?3", | |
| 1164 | "UPDATE lead SET state = ?1, updated_at = ?2, lost_reason = NULL, lost_at = NULL, qualified_at = NULL, closed_kind = NULL, closed_on = NULL WHERE id = ?3", | |
| 1102 | 1165 | ).run( |
| 1103 | 1166 | replied ? "contacted" : "new", |
| 1104 | 1167 | at, |
| @@ -1107,7 +1170,7 @@ export function reopen(db: Database, found: Lead, at: string, agent: Agent): voi | ||
| 1107 | 1170 | const channel = channelFor(found); |
| 1108 | 1171 | const skipped = db |
| 1109 | 1172 | .query<{ id: number; day: number; due_at: string }, [number, number, number]>( |
| 1110 | `SELECT id, day, due_at FROM message WHERE lead_id = ?1 AND status = 'skipped' AND skipped_by_hand = 0 AND day < ?2 AND NOT (day >= ${PRICE_CHANGE_DAY} AND day < ${AFTER_SHOWING_DAY}) AND NOT (day = 0 AND ?3 = 1)`, | |
| 1173 | `SELECT id, day, due_at FROM message WHERE lead_id = ?1 AND status = 'skipped' AND skipped_by_hand = 0 AND day < ?2 AND NOT (day >= ${PRICE_CHANGE_DAY} AND day < ${AFTER_SHOWING_DAY}) AND NOT (${ANNIVERSARIES}) AND NOT (day = 0 AND ?3 = 1)`, | |
| 1111 | 1174 | ) |
| 1112 | 1175 | .all(found.id, ANSWER_DAY, replied ? 1 : 0); |
| 1113 | 1176 | for (const m of skipped) |
| @@ -1125,6 +1188,7 @@ export function reopen(db: Database, found: Lead, at: string, agent: Agent): voi | ||
| 1125 | 1188 | db.query( |
| 1126 | 1189 | `DELETE FROM message WHERE lead_id = ?1 AND day >= ${CHECK_IN_DAY} AND day < ${AFTER_SHOWING_DAY} AND status = 'skipped'`, |
| 1127 | 1190 | ).run(found.id); |
| 1191 | db.query(`DELETE FROM message WHERE lead_id = ?1 AND ${ANNIVERSARIES} AND status IN ${UNSENT_SQL}`).run(found.id); | |
| 1128 | 1192 | const showing = showingOf(db, found.id); |
| 1129 | 1193 | if (showing !== null) |
| 1130 | 1194 | confirmShowing(db, findLead(db, found.id)!, showing.at, showing.address, at, agent); |
| @@ -1229,6 +1293,8 @@ export function bookShowing( | ||
| 1229 | 1293 | /** Whether a message could still be taken back or moved: nothing has gone, and nothing is approved to go (a held one is approved, so it is neither). */ |
| 1230 | 1294 | const UNSENT: readonly string[] = ["draft", "scheduled", "due", "skipped"]; |
| 1231 | 1295 | const UNSENT_SQL = `(${UNSENT.map((status) => `'${status}'`).join(", ")})`; |
| 1296 | /** The anniversary check-ins' days, as a SQL condition on `day`. */ | |
| 1297 | const ANNIVERSARIES = `day > ${ANNIVERSARY_DAY} AND day < ${PRICE_CHANGE_DAY}`; | |
| 1232 | 1298 | |
| 1233 | 1299 | /** |
| 1234 | 1300 | * The note for the morning after the showing at `when`, drafted for the |
src/mcp.ts +5 −3
| @@ -353,18 +353,20 @@ export const tools: readonly Tool[] = [ | ||
| 353 | 353 | }, |
| 354 | 354 | { |
| 355 | 355 | name: "mark_lead", |
| 356 | description: "Mark a lead qualified or lost; it leaves the follow-up plan; 'open' puts a closed lead back in it. Lost needs a reason: bought_elsewhere, not_ready, never_answered, not_a_lead or other.", | |
| 356 | description: "Mark a lead qualified, lost or closed; it leaves the follow-up plan; 'open' puts a closed lead back in it. Lost needs a reason: bought_elsewhere, not_ready, never_answered, not_a_lead or other. Closed is a deal done: kind bought or sold, and closed_on, the closing date (YYYY-MM-DD, not more than a year from today); each year on that date a home-anniversary check-in draft waits on the desk for approval, and nothing is sent.", | |
| 357 | 357 | inputSchema: { |
| 358 | 358 | type: "object", |
| 359 | 359 | properties: { |
| 360 | 360 | id: { type: "integer" }, |
| 361 | state: { enum: ["qualified", "lost", "open"] }, | |
| 361 | state: { enum: ["qualified", "lost", "closed", "open"] }, | |
| 362 | 362 | reason: { enum: [...LOST_REASONS], description: "Why the lead is lost; required when state is lost." }, |
| 363 | kind: { enum: ["bought", "sold"], description: "Whether the client bought or sold with the agent; required when state is closed." }, | |
| 364 | closed_on: { type: "string", description: "The closing date, YYYY-MM-DD; required when state is closed." }, | |
| 363 | 365 | }, |
| 364 | 366 | required: ["id", "state"], |
| 365 | 367 | }, |
| 366 | 368 | run: (db, who, args) => |
| 367 | setState(db, who, { id: Number(args.id), state: args.state, reason: args.reason }), | |
| 369 | setState(db, who, { id: Number(args.id), state: args.state, reason: args.reason, kind: args.kind, closed_on: args.closed_on }), | |
| 368 | 370 | }, |
| 369 | 371 | { |
| 370 | 372 | name: "edit_lead", |
src/pages/activity.ts +13 −1
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import type { LostCount } from "../lost.ts"; |
| 7 | import { KIND_LABEL, type DealCount } from "../closed.ts"; | |
| 7 | 8 | import type { Activity } from "../actions.ts"; |
| 8 | 9 | import type { AuditRow } from "../audit.ts"; |
| 9 | 10 | import type { Agent } from "../env.ts"; |
| @@ -31,6 +32,15 @@ function sentence(row: AuditRow, names: ReadonlyMap<number, string>, zone: strin | ||
| 31 | 32 | return `<li class="activity-row">${text}${refused} <span class="muted">· <time datetime="${escape(row.at)}">${escape(when(row.at, zone))}</time></span></li>`; |
| 32 | 33 | } |
| 33 | 34 | |
| 35 | /** Deals closed in the last 30 days, counted by kind, beside the lost counts. */ | |
| 36 | function dealsBlock(deals: readonly DealCount[]): string { | |
| 37 | const items = deals.map((d) => `<li><strong>${d.count}</strong> ${escape(KIND_LABEL[d.kind])}</li>`).join(""); | |
| 38 | return `<section class="card stack" data-closed-deals aria-label="Deals closed"> | |
| 39 | <h2>Closed in the last 30 days</h2> | |
| 40 | ${items === "" ? `<p class="muted">No deal was closed.</p>` : `<ul>${items}</ul>`} | |
| 41 | </section>`; | |
| 42 | } | |
| 43 | ||
| 34 | 44 | /** Leads lost in the last 30 days, counted by the reason the agent gave. */ |
| 35 | 45 | function lostBlock(lost: readonly LostCount[]): string { |
| 36 | 46 | const items = lost |
| @@ -49,9 +59,10 @@ export function activityPage(input: { | ||
| 49 | 59 | names: ReadonlyMap<number, string>; |
| 50 | 60 | older: number | undefined; |
| 51 | 61 | lost: readonly LostCount[]; |
| 62 | deals?: readonly DealCount[]; | |
| 52 | 63 | sentOnly: boolean; |
| 53 | 64 | }): string { |
| 54 | const { who, agent, rows, names, older, lost, sentOnly } = input; | |
| 65 | const { who, agent, rows, names, older, lost, deals = [], sentOnly } = input; | |
| 55 | 66 | const filter = sentOnly ? "&show=sent" : ""; |
| 56 | 67 | const links = `<nav class="row" aria-label="Filter"> |
| 57 | 68 | <a class="button button-quiet" href="/activity"${sentOnly ? "" : ' aria-current="page"'}>Everything</a> |
| @@ -69,6 +80,7 @@ export function activityPage(input: { | ||
| 69 | 80 | body: `<p><a class="backlink" href="/">← All leads</a></p> |
| 70 | 81 | <h1>Activity</h1> |
| 71 | 82 | <p class="muted">Every change, newest first, and who made it. Refused attempts are shown too. Times are in ${escape(agent.timeZone)}.</p> |
| 83 | ${dealsBlock(deals)} | |
| 72 | 84 | ${lostBlock(lost)} |
| 73 | 85 | ${links} |
| 74 | 86 | ${list} |
src/pages/lead.ts +6 −1
| @@ -20,6 +20,7 @@ import type { Who } from "../who.ts"; | ||
| 20 | 20 | import { layout } from "./layout.ts"; |
| 21 | 21 | import { FINISHED_ACTION, FINISHED_LABEL } from "../finished.ts"; |
| 22 | 22 | import { LOST_REASONS, REASON_LABEL, lostSaid } from "../lost.ts"; |
| 23 | import { CLOSED_KINDS, closedSaid } from "../closed.ts"; | |
| 23 | 24 | import { clock, consent, day, source, starForm, STATE_LABEL, when } from "./parts.ts"; |
| 24 | 25 | |
| 25 | 26 | /** One line of the thread: something we sent, or something they wrote back. */ |
| @@ -99,7 +100,7 @@ export function leadPage(input: { | ||
| 99 | 100 | closed |
| 100 | 101 | ? lead.state === "lost" |
| 101 | 102 | ? `Lost — ${escape(lostSaid(lead))}${lead.lostAt === null ? "" : `, ${escape(day(lead.lostAt, agent.timeZone))}`}: out of the follow-up plan.` |
| 102 | : `Marked ${STATE_LABEL[lead.state].toLowerCase()}: out of the follow-up plan.` | |
| 103 | : `${escape(closedSaid(lead) ?? `Marked ${STATE_LABEL[lead.state].toLowerCase()}`)}: out of the follow-up plan.${closedSaid(lead) === null ? "" : " A home-anniversary check-in will wait on the desk each year."}` | |
| 103 | 104 | : lead.messages.some((m) => m.status === "scheduled") |
| 104 | 105 | ? "The next follow-up is drafted below and comes due on its day." |
| 105 | 106 | : lead.finished |
| @@ -216,6 +217,10 @@ export function leadPage(input: { | ||
| 216 | 217 | <form method="post" action="${path}/state"><input type="hidden" name="state" value="lost"> |
| 217 | 218 | <label>Why? <select name="reason" required>${LOST_REASONS.map((r) => `<option value="${r}">${escape(REASON_LABEL[r])}</option>`).join("")}</select></label> |
| 218 | 219 | <button class="button button-quiet">Mark lost</button></form> |
| 220 | <form method="post" action="${path}/state"><input type="hidden" name="state" value="closed"> | |
| 221 | <label>Closed <select name="kind" required>${CLOSED_KINDS.map((k) => `<option value="${k}">${k === "bought" ? "Closed — bought" : "Closed — sold"}</option>`).join("")}</select></label> | |
| 222 | <label>On <input type="date" name="closed_on" required></label> | |
| 223 | <button class="button button-quiet">Mark closed</button></form> | |
| 219 | 224 | </section>` |
| 220 | 225 | : mayUpdate |
| 221 | 226 | ? `<section class="states"> |
src/routes.ts +27 −7
| @@ -89,6 +89,7 @@ import { MAX_EMAIL_TEXT } from "./intake.ts"; | ||
| 89 | 89 | import { MAX_SIGNIN_TEXT } from "./signins.ts"; |
| 90 | 90 | import { MAX_CSV, MAX_ROWS } from "./import.ts"; |
| 91 | 91 | import { LOST_REASONS, lostSaid } from "./lost.ts"; |
| 92 | import { CLOSED_KINDS, closedSaid } from "./closed.ts"; | |
| 92 | 93 | import type { ImportPreview } from "./actions.ts"; |
| 93 | 94 | import { findLead, MAX_INBOUND, MAX_LEAD_NOTE, MAX_QUERY, Q_HELP, showingsAhead, VIEW_HELP, VIEWS, type LeadDetail, type NewLead } from "./leads.ts"; |
| 94 | 95 | import { dueSaid } from "./sentences.ts"; |
| @@ -197,6 +198,15 @@ const LEAD_FIELDS = { | ||
| 197 | 198 | anyOf: [{ enum: [...LOST_REASONS] }, { type: "null" }], |
| 198 | 199 | description: "Why the lead was lost; null unless it is lost, and for a lead lost before reasons were asked for.", |
| 199 | 200 | }, |
| 201 | closedKind: { | |
| 202 | anyOf: [{ enum: [...CLOSED_KINDS] }, { type: "null" }], | |
| 203 | description: "For a deal closed: bought or sold with the agent; null otherwise.", | |
| 204 | }, | |
| 205 | closedOn: { | |
| 206 | type: ["string", "null"], | |
| 207 | pattern: "^\\d{4}-\\d{2}-\\d{2}$", | |
| 208 | description: "For a deal closed: the closing date (YYYY-MM-DD, the profile's calendar); each year on it a home-anniversary check-in draft waits on the desk. Null otherwise.", | |
| 209 | }, | |
| 200 | 210 | lostAt: { |
| 201 | 211 | type: ["string", "null"], |
| 202 | 212 | format: "date-time", |
| @@ -756,6 +766,16 @@ const REASON_FIELD = { | ||
| 756 | 766 | description: "Why the lead is lost; required when state is lost.", |
| 757 | 767 | } as const; |
| 758 | 768 | |
| 769 | const KIND_FIELD = { | |
| 770 | enum: [...CLOSED_KINDS], | |
| 771 | description: "Whether the client bought or sold with the agent; required when state is closed.", | |
| 772 | } as const; | |
| 773 | ||
| 774 | const CLOSED_ON_FIELD = { | |
| 775 | type: "string", | |
| 776 | description: "The closing date, YYYY-MM-DD, not more than a year from today in the profile's time zone; required when state is closed (422 naming closed_on otherwise).", | |
| 777 | } as const; | |
| 778 | ||
| 759 | 779 | const BACK_ON_FIELD = { |
| 760 | 780 | type: ["string", "null"], |
| 761 | 781 | description: "YYYY-MM-DD: put the lead away until then; null brings it back now.", |
| @@ -1301,14 +1321,14 @@ export const routes: readonly Route[] = [ | ||
| 1301 | 1321 | if (noted) return answer(setNote(c.db, c.who, { id: ID(c), note: input.note })); |
| 1302 | 1322 | if (details) |
| 1303 | 1323 | return answer(editLead(c.db, c.who, { id: ID(c), ...contactOf(input) })); |
| 1304 | return answer(setState(c.db, c.who, { id: ID(c), state: input.state, reason: input.reason })); | |
| 1324 | return answer(setState(c.db, c.who, { id: ID(c), state: input.state, reason: input.reason, kind: input.kind, closed_on: input.closed_on })); | |
| 1305 | 1325 | }, |
| 1306 | 1326 | api: { |
| 1307 | 1327 | summary: |
| 1308 | "Mark a lead qualified or lost (the same as POST /leads/{id}/state), correct its name, email or phone (the same as POST /leads/{id}/edit), keep a private note on it (the same as POST /leads/{id}/note), star it or take the star off with starred (true or false), or put it away until a day with back_on (YYYY-MM-DD, from tomorrow to 365 days out in the profile's time zone; null brings it back now): send one of the five. Putting it away sets unsent follow-ups aside, and at 8:00 that morning a check-in draft waits on the desk; a bad date is a 422 naming back_on.", | |
| 1328 | "Mark a lead qualified, lost or closed (bought or sold, with a closing date; the same as POST /leads/{id}/state), correct its name, email or phone (the same as POST /leads/{id}/edit), keep a private note on it (the same as POST /leads/{id}/note), star it or take the star off with starred (true or false), or put it away until a day with back_on (YYYY-MM-DD, from tomorrow to 365 days out in the profile's time zone; null brings it back now): send one of the five. Putting it away sets unsent follow-ups aside, and at 8:00 that morning a check-in draft waits on the desk; a bad date is a 422 naming back_on.", | |
| 1309 | 1329 | body: { |
| 1310 | 1330 | type: "object", |
| 1311 | properties: { state: { enum: ["qualified", "lost", "open"] }, reason: REASON_FIELD, ...EDIT_FIELDS, note: NOTE_FIELD, back_on: BACK_ON_FIELD, starred: STARRED_FIELD }, | |
| 1331 | properties: { state: { enum: ["qualified", "lost", "closed", "open"] }, reason: REASON_FIELD, kind: KIND_FIELD, closed_on: CLOSED_ON_FIELD, ...EDIT_FIELDS, note: NOTE_FIELD, back_on: BACK_ON_FIELD, starred: STARRED_FIELD }, | |
| 1312 | 1332 | }, |
| 1313 | 1333 | answer: LEAD, |
| 1314 | 1334 | }, |
| @@ -1605,17 +1625,17 @@ export const routes: readonly Route[] = [ | ||
| 1605 | 1625 | handle: (c) => |
| 1606 | 1626 | posted( |
| 1607 | 1627 | c, |
| 1608 | (input) => setState(c.db, c.who, { id: ID(c), state: input.state, reason: input.reason }), | |
| 1628 | (input) => setState(c.db, c.who, { id: ID(c), state: input.state, reason: input.reason, kind: input.kind, closed_on: input.closed_on }), | |
| 1609 | 1629 | (lead) => ({ |
| 1610 | 1630 | to: `/leads/${ID(c)}`, |
| 1611 | ...(lead === undefined ? {} : { said: lead.state === "qualified" ? "Marked qualified." : lead.state === "lost" ? `Marked lost: ${lostSaid(lead)}.` : "Back in follow-ups." }), | |
| 1631 | ...(lead === undefined ? {} : { said: lead.state === "qualified" ? (closedSaid(lead) === null ? "Marked qualified." : `${closedSaid(lead)}.`) : lead.state === "lost" ? `Marked lost: ${lostSaid(lead)}.` : "Back in follow-ups." }), | |
| 1612 | 1632 | }), |
| 1613 | 1633 | ), |
| 1614 | 1634 | api: { |
| 1615 | summary: "Mark a lead qualified or lost (with a reason): it leaves the follow-up plan. 'open' puts a closed lead back in it.", | |
| 1635 | summary: "Mark a lead qualified, lost (with a reason) or closed (kind bought or sold, and closed_on, the closing date): it leaves the follow-up plan. A closed deal gets a home-anniversary check-in draft on the desk each year on its date. 'open' puts a closed lead back in it.", | |
| 1616 | 1636 | body: { |
| 1617 | 1637 | type: "object", |
| 1618 | properties: { state: { enum: ["qualified", "lost", "open"] }, reason: REASON_FIELD }, | |
| 1638 | properties: { state: { enum: ["qualified", "lost", "closed", "open"] }, reason: REASON_FIELD, kind: KIND_FIELD, closed_on: CLOSED_ON_FIELD }, | |
| 1619 | 1639 | required: ["state"], |
| 1620 | 1640 | }, |
| 1621 | 1641 | answer: LEAD, |
src/sample.ts +13
| @@ -12,8 +12,10 @@ import type { Source } from "./drafts.ts"; | ||
| 12 | 12 | import { morningAfter, wallAt } from "./showings.ts"; |
| 13 | 13 | import { currentAgent } from "./profile.ts"; |
| 14 | 14 | import { today } from "./putaway.ts"; |
| 15 | import type { ClosedKind } from "./closed.ts"; | |
| 15 | 16 | import { |
| 16 | 17 | advance, |
| 18 | anniversaries, | |
| 17 | 19 | bookShowing, |
| 18 | 20 | putAway, |
| 19 | 21 | close, |
| @@ -34,6 +36,8 @@ interface Sample extends NewLead { | ||
| 34 | 36 | /** The cadence days already sent. */ |
| 35 | 37 | readonly touched?: readonly number[]; |
| 36 | 38 | readonly closed?: Closing; |
| 39 | /** A past client: the deal closed this many years ago today on the agent's calendar, so its anniversary check-in waits on the desk. */ | |
| 40 | readonly pastClient?: { readonly years: number; readonly kind: ClosedKind }; | |
| 37 | 41 | /** A showing already held, this long ago (rounded to the agent's morning after it). */ |
| 38 | 42 | readonly showedAgo?: number; |
| 39 | 43 | /** A showing booked ahead: this many days from today at 10:00 in the agent's zone. */ |
| @@ -96,6 +100,7 @@ export const SAMPLES: readonly Sample[] = [ | ||
| 96 | 100 | person("Gus Moreno", "website", false, "What are closing costs for a $450k purchase here?", { ago: 42 * D, answeredIn: 22 * M, touched: [1, 3], closed: { state: "lost", reason: "bought_elsewhere" } }), |
| 97 | 101 | person("Imani Reed", "referral", false, "Referred by a coworker. Looking to buy within the year.", { ago: 47 * D, answeredIn: 7 * M, touched: [1, 3], closed: { state: "lost", reason: "not_ready" } }), |
| 98 | 102 | person("Pavel Sokol", "open_house", false, "Signed in at the Cedar Ct open house; send details please.", { ago: 52 * D, answeredIn: 11 * M, touched: [1, 3], closed: { state: "lost", reason: "not_ready" } }), |
| 103 | person("Tom Whitfield", "referral", false, "Our friends the Parks sent us your way. We want a 3-bedroom near the lake and would like to move in the spring.", { ago: 380 * D, answeredIn: 4 * M + 10 * S, touched: [1, 3], pastClient: { years: 1, kind: "bought" } }), | |
| 99 | 104 | person("Ruth Okafor", "website", false, "Looking at starter homes under $400k. Can you send a few options?", { ago: 34 * D, answeredIn: 2 * M + 10 * S, touched: [1, 3, 7, 14, 30] }), |
| 100 | 105 | ]; |
| 101 | 106 | |
| @@ -135,8 +140,16 @@ export function seed(db: Database, now: Date): number { | ||
| 135 | 140 | if (sample.note !== undefined) |
| 136 | 141 | writeNote(db, findLead(db, id)!, sample.note, iso(asked + 30 * M)); |
| 137 | 142 | if (sample.closed !== undefined) close(db, id, sample.closed, iso(t - 2 * D)); |
| 143 | if (sample.pastClient !== undefined) { | |
| 144 | const agent = currentAgent(db); | |
| 145 | const here = today(t, agent.timeZone); | |
| 146 | const on = `${+here.slice(0, 4) - sample.pastClient.years}${here.slice(4) === "-02-29" ? "-02-28" : here.slice(4)}`; | |
| 147 | bookShowing(db, findLead(db, id)!, iso(wallAt(t - 380 * D + 9 * D, 0, 10, agent.timeZone) ?? t - 371 * D), "14 Lakeview Ct", iso(asked + 30 * M), agent); | |
| 148 | close(db, id, { state: "qualified", deal: { kind: sample.pastClient.kind, on } }, iso(t - sample.pastClient.years * 365 * D)); | |
| 149 | } | |
| 138 | 150 | } |
| 139 | 151 | advance(db, iso(t)); |
| 152 | anniversaries(db, t, currentAgent(db)); | |
| 140 | 153 | return SAMPLES.length; |
| 141 | 154 | } |
| 142 | 155 |
src/search.test.ts +2 −2
| @@ -37,8 +37,8 @@ test("a name, an email or a phone finds the lead, in any case", () => { | ||
| 37 | 37 | }); |
| 38 | 38 | |
| 39 | 39 | test("no search is the whole desk", () => { |
| 40 | expect(names({}).length).toBe(20); | |
| 41 | expect(names({ q: "" }).length).toBe(20); | |
| 40 | expect(names({}).length).toBe(21); | |
| 41 | expect(names({ q: "" }).length).toBe(21); | |
| 42 | 42 | }); |
| 43 | 43 | |
| 44 | 44 | test("a search combines with the view", () => { |
src/sentences.ts +19
| @@ -33,6 +33,18 @@ function mergeWords(detail: string | null, esc: (text: string) => string): strin | ||
| 33 | 33 | return label === undefined ? "" : ` (via ${esc(label)})`; |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | /** A closing's kind and date: " (bought, Mar 1, 2026)"; "" when the row names none. */ | |
| 37 | function dealWords(detail: string | null): string { | |
| 38 | try { | |
| 39 | const { kind, on } = JSON.parse(detail ?? "") as { kind?: unknown; on?: unknown }; | |
| 40 | const date = typeof on === "string" && /^\d{4}-\d{2}-\d{2}$/.test(on) ? dateName(on) : ""; | |
| 41 | const said = [kind === "bought" || kind === "sold" ? kind : "", date].filter((x) => x !== "").join(", "); | |
| 42 | return said === "" ? "" : ` (${said})`; | |
| 43 | } catch { | |
| 44 | return ""; | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 36 | 48 | /** A put-away's date: " until Mar 1, 2027"; "" when the row names none. */ |
| 37 | 49 | function awayWords(detail: string | null, esc: (text: string) => string): string { |
| 38 | 50 | try { |
| @@ -165,6 +177,13 @@ export function deed( | ||
| 165 | 177 | done: `marked ${lead} ${row.act}`, |
| 166 | 178 | tried: `mark ${lead} ${row.act}`, |
| 167 | 179 | }; |
| 180 | if (row.act === "closed") { | |
| 181 | const deal = dealWords(row.detail); | |
| 182 | return { | |
| 183 | done: `marked ${lead} closed${deal}`, | |
| 184 | tried: `mark ${lead} closed`, | |
| 185 | }; | |
| 186 | } | |
| 168 | 187 | if (row.act === "inbound") |
| 169 | 188 | return { |
| 170 | 189 | done: `logged what ${lead} wrote back`, |
src/terminal.test.ts +1 −1
| @@ -416,7 +416,7 @@ test("x on the desk shows the leads as CSV, for those who may list; esc goes bac | ||
| 416 | 416 | const t = terminal(db, alice); |
| 417 | 417 | t.key("x"); |
| 418 | 418 | const screen = read(t); |
| 419 | expect(screen).toContain("20 leads as CSV"); | |
| 419 | expect(screen).toContain("21 leads as CSV"); | |
| 420 | 420 | expect(screen).toContain("name,email,phone,source,status"); |
| 421 | 421 | expect(screen).toContain("Maria Gonzalez"); |
| 422 | 422 | t.key("\x1b"); |
src/terminal.ts +38 −6
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | * due j/k move · space tick · s send the ticked ones (each shown in full) · esc back |
| 10 | 10 | |
| 11 | 11 | * lead s approve and send · e edit, then send · w they wrote back · ] next lead that needs you · |
| 12 | * q qualified · x lost · m lost, never answered (follow-ups finished) · t asked to stop texts · D remove (admin) · esc back | |
| 12 | * q qualified · y closed (bought or sold, with the date) · x lost · m lost, never answered (follow-ups finished) · t asked to stop texts · D remove (admin) · esc back | |
| 13 | 13 | * edit type; enter is a new line · ctrl-d approve and send · esc cancel |
| 14 | 14 | * wrote type what they wrote · ctrl-t text/email (text only with consent) · ctrl-d record it and draft the answer · esc cancel |
| 15 | 15 | * paste paste the notification email · ctrl-d read it, then check each field in the add screen (consent starts as n) · esc cancel |
| @@ -85,10 +85,11 @@ import { permit } from "./contract.ts"; | ||
| 85 | 85 | import { REPORT_ROWS } from "./report-words.ts"; |
| 86 | 86 | import { goesLabel, isQuiet, nextOpening, RECORD_NOTE } from "./quiet.ts"; |
| 87 | 87 | import { dueSaid, said as saidOf } from "./sentences.ts"; |
| 88 | import { SOURCE_LABEL, SOURCES, channelFor, dayLabel, isAfterShowing, isAnswer, isBeforeShowing, isCheckIn, isPriceChange, planLabel, type Channel, type Source } from "./drafts.ts"; | |
| 88 | import { SOURCE_LABEL, SOURCES, channelFor, dayLabel, isAfterShowing, isAnniversary, isAnswer, isBeforeShowing, isCheckIn, isPriceChange, planLabel, type Channel, type Source } from "./drafts.ts"; | |
| 89 | 89 | import { ssh, title, where } from "./env.ts"; |
| 90 | 90 | import { FINISHED_ACTION, FINISHED_LABEL } from "./finished.ts"; |
| 91 | 91 | import { LOST_REASONS, REASON_LABEL, lostSaid } from "./lost.ts"; |
| 92 | import { CLOSED_KINDS, closedSaid, type ClosedKind } from "./closed.ts"; | |
| 92 | 93 | import { answerSince, CLOSED, MAX_QUERY, VIEWS, queryOf, type DeskLead, type LeadDetail, type Message, type View as DeskView } from "./leads.ts"; |
| 93 | 94 | import { day as dayOn, when } from "./pages/parts.ts"; |
| 94 | 95 | import { currentAgent } from "./profile.ts"; |
| @@ -206,6 +207,7 @@ type View = | ||
| 206 | 207 | | { name: "wrote"; id: number; text: string; channel: Channel } |
| 207 | 208 | | { name: "note"; id: number; text: string } |
| 208 | 209 | | { name: "lost"; id: number } |
| 210 | | { name: "deal"; id: number; kind: ClosedKind | null; typed: string } | |
| 209 | 211 | | { name: "details"; id: number; step: number; typed: string; fields: Record<string, string> } |
| 210 | 212 | | { name: "paste"; text: string } |
| 211 | 213 | | { name: "import"; text: string; preview?: ImportPreview } |
| @@ -325,12 +327,12 @@ export function terminal( | ||
| 325 | 327 | l.goes_at !== null |
| 326 | 328 | ? dim(goesLabel(l.goes_at, now(), zone)) |
| 327 | 329 | : l.ready === null |
| 328 | ? dim(l.state === "qualified" || l.state === "lost" ? l.state : l.back_on !== null ? `back on ${dateName(l.back_on)}` : l.finished ? FINISHED_LABEL : "") | |
| 330 | ? dim(l.state === "qualified" || l.state === "lost" ? (closedSaid(l) === null ? l.state : closedSaid(l)!) : l.back_on !== null ? `back on ${dateName(l.back_on)}` : l.finished ? FINISHED_LABEL : "") | |
| 329 | 331 | : l.ready.day === 0 |
| 330 | 332 | ? "reply drafted" |
| 331 | 333 | : isAnswer(l.ready.day) |
| 332 | 334 | ? "wrote back · answer drafted" |
| 333 | : isPriceChange(l.ready.day) ? "price change · note ready" : isCheckIn(l.ready.day) ? "check-in ready" : isBeforeShowing(l.ready.day) ? "before the showing · confirmation ready" : isAfterShowing(l.ready.day) ? "after the showing · note ready" : `day ${l.ready.day} follow-up ready`; | |
| 335 | : isAnniversary(l.ready.day) ? "home anniversary · check-in ready" : isPriceChange(l.ready.day) ? "price change · note ready" : isCheckIn(l.ready.day) ? "check-in ready" : isBeforeShowing(l.ready.day) ? "before the showing · confirmation ready" : isAfterShowing(l.ready.day) ? "after the showing · note ready" : `day ${l.ready.day} follow-up ready`; | |
| 334 | 336 | const name = `${l.starred ? "★ " : ""}${clean(l.name)}`.padEnd(18).slice(0, 18); |
| 335 | 337 | const showing = |
| 336 | 338 | l.showing === null |
| @@ -457,7 +459,7 @@ export function terminal( | ||
| 457 | 459 | lines.push( |
| 458 | 460 | dim( |
| 459 | 461 | l.state === "qualified" || l.state === "lost" |
| 460 | ? `Marked ${l.state}${l.state === "lost" ? ` — ${lostSaid(l)}` : ""}: out of the follow-up plan.` | |
| 462 | ? `${closedSaid(l) ?? `Marked ${l.state}${l.state === "lost" ? ` — ${lostSaid(l)}` : ""}`}: out of the follow-up plan.` | |
| 461 | 463 | : next !== undefined |
| 462 | 464 | ? `Nothing to send now. ${titleOf(next)} comes due ${next.due_at.slice(0, 10)}.` |
| 463 | 465 | : l.finished |
| @@ -528,7 +530,7 @@ export function terminal( | ||
| 528 | 530 | ...(l.back_on === null ? [] : ["A bring back now"]), |
| 529 | 531 | ...(l.state === "qualified" || l.state === "lost" |
| 530 | 532 | ? ["o put back in follow-ups"] |
| 531 | : ["q qualified", "x lost"]), | |
| 533 | : ["q qualified", "y closed: bought or sold", "x lost"]), | |
| 532 | 534 | ...(l.finished && mayEdit(l) ? [`m ${FINISHED_ACTION.toLowerCase()}`] : []), |
| 533 | 535 | ...(mayEdit(l) ? ["d fix details"] : []), |
| 534 | 536 | ...(mayNote(l) ? [l.note === "" ? "n add a note" : "n edit your note"] : []), |
| @@ -817,6 +819,16 @@ export function terminal( | ||
| 817 | 819 | "", |
| 818 | 820 | dim("type a note · enter new line · ctrl-o outcome · ctrl-d log the call · esc cancel"), |
| 819 | 821 | ] |
| 822 | : view.name === "deal" | |
| 823 | ? [ | |
| 824 | bold(`Closed deal with ${clean(lead(view.id)?.name ?? "the lead")}`), | |
| 825 | "", | |
| 826 | ...(view.kind === null | |
| 827 | ? CLOSED_KINDS.map((k, i) => ` ${i + 1} Closed — ${k}`) | |
| 828 | : [`Closed — ${view.kind}`, `Closing date (YYYY-MM-DD, at most a year ahead): ${clean(view.typed)}█`]), | |
| 829 | "", | |
| 830 | dim(view.kind === null ? "1-2 choose · esc cancel" : "enter mark it closed · esc cancel"), | |
| 831 | ] | |
| 820 | 832 | : view.name === "lost" |
| 821 | 833 | ? [ |
| 822 | 834 | bold(`Why was ${clean(lead(view.id)?.name ?? "the lead")} lost?`), |
| @@ -1033,6 +1045,8 @@ export function terminal( | ||
| 1033 | 1045 | said = marked.ok ? `${marked.value.name}: lost, ${REASON_LABEL.never_answered}.` : marked.message; |
| 1034 | 1046 | } else if (input === "x" && !CLOSED.includes(l.state)) |
| 1035 | 1047 | view = { name: "lost", id }; |
| 1048 | else if (input === "y" && !CLOSED.includes(l.state)) | |
| 1049 | view = { name: "deal", id, kind: null, typed: "" }; | |
| 1036 | 1050 | else if (input === "c" && l.phone !== "") |
| 1037 | 1051 | view = { name: "call", id, outcome: "talked", text: "" }; |
| 1038 | 1052 | else if (input === "b" && !CLOSED.includes(l.state)) |
| @@ -1179,6 +1193,24 @@ export function terminal( | ||
| 1179 | 1193 | } |
| 1180 | 1194 | return; |
| 1181 | 1195 | } |
| 1196 | case "deal": { | |
| 1197 | if (input === "\x1b") { | |
| 1198 | said = "Nothing changed."; | |
| 1199 | view = { name: "lead", id: view.id }; | |
| 1200 | } else if (view.kind === null) { | |
| 1201 | const kind = CLOSED_KINDS[Number(input) - 1]; | |
| 1202 | if (kind !== undefined && /^[12]$/.test(input)) view = { ...view, kind }; | |
| 1203 | } else if (input === "\r") { | |
| 1204 | const closed = setState(db, me, { id: view.id, state: "closed", kind: view.kind, closed_on: view.typed }); | |
| 1205 | said = closed.ok ? `${closed.value.name}: ${closedSaid(closed.value)}. A check-in draft waits each year on the date.` : closed.message; | |
| 1206 | // A refused date is typed again; anything else goes back to the lead. | |
| 1207 | if (closed.ok || closed.error !== "invalid") view = { name: "lead", id: view.id }; | |
| 1208 | } else { | |
| 1209 | const next = typing(view.typed, input); | |
| 1210 | if (next !== undefined) view = { ...view, typed: next }; | |
| 1211 | } | |
| 1212 | return; | |
| 1213 | } | |
| 1182 | 1214 | case "lost": { |
| 1183 | 1215 | const reason = LOST_REASONS[Number(input) - 1]; |
| 1184 | 1216 | if (reason !== undefined && /^[1-9]$/.test(input)) { |