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

opened this

Check · Review · Land · Live — It conflicts with its base. Next: builder, it conflicts with its base.

first in the queue: no estimate: nothing landed in the last hour

Conflicts with main

mason/speed-to-lead:mason/90-a-lead-who-buys-or-sells-with-the-agent → main 1 commit fork

  • Can't merge. Conflicts in 2 files: src/db.ts, src/leads.ts. Merge main into the branch and push again.

    1 approval

    Conflicts in 2 filesdeploy 2cad6ad

    Website spec: 4 findings, 4 the edge owns (report only)
    • / no <link rel="canonical"> — canonical
    • / missing og:image — open-graph
    • / the first focusable element is not a skip link (an in-page #target link) — skip-link
    • / a 3929-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:33511/tokens.css?v=d43314d1: a 2299-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:33511/static/app.css?v=b40616cd: a 17333-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / no Cross-Origin-Opener-Policy — coop (the edge owns this)
    • / no ETag on the HTML — caching

Review

Sign in to review this change.
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:
140140 not touch the cadence. `starredFirst` lifts starred open leads to the top of
141141 any view but leaves a first reply past the green clock ahead of them, and does
142142 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.
143151 - `src/thanks.ts` — the thank-you to whoever referred a lead (`referred_by`,
144152 `referrer_email`, `referrer_phone` on the lead; `referrer_thanks` table, one
145153 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.
8686 day; at 8:00 that morning in your time zone a check-in draft waits for you
8787 to approve. **Hannah Wright** is already put away for sixty days. "Bring
8888 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.
8990 Keep your best leads close with the star: tap ☆ on a desk row or under a
9091 lead's name (one tap, no JavaScript needed). Starred open leads sort first
9192 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`
394395 change to a lead, and the Activity page says "put … away until …" and "brought
395396 … back".
396397
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
397409 Starring a lead keeps it close: `PATCH /leads/:id` with `{"starred": true}` (or
398410 `false`; `422` for anything else), the page's `POST /leads/:id/star`, the MCP
399411 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 @@
13341334 }
13351335 },
13361336 "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.",
13381338 "operationId": "patchLeadsId",
13391339 "parameters": [
13401340 {
@@ -1357,6 +1357,7 @@
13571357 "enum": [
13581358 "qualified",
13591359 "lost",
1360 "closed",
13601361 "open"
13611362 ]
13621363 },
@@ -1370,6 +1371,17 @@
13701371 ],
13711372 "description": "Why the lead is lost; required when state is lost."
13721373 },
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 },
13731385 "name": {
13741386 "type": "string",
13751387 "minLength": 1,
@@ -1407,7 +1419,7 @@
14071419 },
14081420 "responses": {
14091421 "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.",
14111423 "content": {
14121424 "application/json": {
14131425 "schema": {
@@ -1602,6 +1614,28 @@
16021614 ],
16031615 "description": "Why the lead was lost; null unless it is lost, and for a lead lost before reasons were asked for."
16041616 },
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 },
16051639 "lostAt": {
16061640 "type": [
16071641 "string",
@@ -1865,6 +1899,8 @@
18651899 "opted_out_at",
18661900 "state",
18671901 "lostReason",
1902 "closedKind",
1903 "closedOn",
18681904 "lostAt",
18691905 "back_on",
18701906 "starred",
@@ -2305,7 +2341,7 @@
23052341 },
23062342 "/leads/{id}/state": {
23072343 "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.",
23092345 "operationId": "postLeadsIdState",
23102346 "parameters": [
23112347 {
@@ -2328,6 +2364,7 @@
23282364 "enum": [
23292365 "qualified",
23302366 "lost",
2367 "closed",
23312368 "open"
23322369 ]
23332370 },
@@ -2340,6 +2377,17 @@
23402377 "other"
23412378 ],
23422379 "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)."
23432391 }
23442392 },
23452393 "required": [
@@ -2351,7 +2399,7 @@
23512399 },
23522400 "responses": {
23532401 "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.",
23552403 "content": {
23562404 "application/json": {
23572405 "schema": {
@@ -3441,6 +3489,28 @@
34413489 ],
34423490 "description": "Why the lead was lost; null unless it is lost, and for a lead lost before reasons were asked for."
34433491 },
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 },
34443514 "lostAt": {
34453515 "type": [
34463516 "string",
@@ -3680,6 +3750,8 @@
36803750 "opted_out_at",
36813751 "state",
36823752 "lostReason",
3753 "closedKind",
3754 "closedOn",
36833755 "lostAt",
36843756 "back_on",
36853757 "starred",
@@ -3932,6 +4004,28 @@
39324004 ],
39334005 "description": "Why the lead was lost; null unless it is lost, and for a lead lost before reasons were asked for."
39344006 },
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 },
39354029 "lostAt": {
39364030 "type": [
39374031 "string",
@@ -4072,6 +4166,8 @@
40724166 "opted_out_at",
40734167 "state",
40744168 "lostReason",
4169 "closedKind",
4170 "closedOn",
40754171 "lostAt",
40764172 "back_on",
40774173 "starred",
src/actions.test.ts +5 −4
@@ -226,10 +226,10 @@ test("qualified or lost leaves the cadence", () => {
226226 if (!gone.ok) expect(gone.error).toBe("not_found");
227227 });
228228
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", () => {
230230 const sampleDb = openDb(mkdtempSync(join(tmpdir(), "app-sample-")));
231231 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);
233233 const listed = must(listLeads(sampleDb, alice, { now: T0.getTime() }));
234234 // A believable first record: 14 leads in 30 days, 57% answered or waiting under 5 minutes.
235235 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
260260 const show = (v: unknown) => must(listLeads(sampleDb, alice, { ...at, show: v }));
261261 const needs = show("needs"), open = show("open"), closed = show("closed");
262262 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);
264265 expect(open.leads.every((l) => l.ready === null && !["qualified", "lost"].includes(l.state))).toBe(true);
265266 expect(closed.leads.every((l) => ["qualified", "lost"].includes(l.state))).toBe(true);
266267 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
269270 // A lead put away until a date is in none of the three, only in all.
270271 const away = everyone.filter((l) => l.back_on !== null);
271272 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());
273274 expect(needs.counts).toEqual({
274275 needs: needs.leads.length,
275276 open: open.leads.length,
src/actions.ts +22 −5
@@ -8,6 +8,7 @@
88
99 import type { Database } from "bun:sqlite";
1010 import { REASON_HELP, lostCounts, reasonOf, type LostCount } from "./lost.ts";
11 import { KIND_HELP, closedCounts, closingOf, kindOf, type DealCount } from "./closed.ts";
1112 import { csvName, leadsCsv } from "./export.ts";
1213 import { mergeDetail, record, type Act, type AuditRow } from "./audit.ts";
1314 import { MAX_NOTE, outcomeOf } from "./calls.ts";
@@ -29,6 +30,7 @@ import {
2930 advance,
3031 clearLeads,
3132 CLOSED,
33 anniversaries,
3234 close,
3335 countViews,
3436 desk,
@@ -1400,7 +1402,7 @@ function restoring(
14001402 export const setState = (
14011403 db: Database,
14021404 who: Who,
1403 input: { id: number; state: unknown; reason?: unknown },
1405 input: { id: number; state: unknown; reason?: unknown; kind?: unknown; closed_on?: unknown },
14041406 at?: Date,
14051407 ): Result<LeadDetail> =>
14061408 audited(
@@ -1410,17 +1412,20 @@ export const setState = (
14101412 "update",
14111413 input.id,
14121414 mark(db, who, input, at),
1413 input.state === "qualified" || input.state === "lost"
1415 input.state === "qualified" || input.state === "lost" || input.state === "closed"
14141416 ? input.state
14151417 : input.state === "open"
14161418 ? "reopened"
14171419 : 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,
14181423 );
14191424
14201425 function mark(
14211426 db: Database,
14221427 who: Who,
1423 input: { id: number; state: unknown; reason?: unknown },
1428 input: { id: number; state: unknown; reason?: unknown; kind?: unknown; closed_on?: unknown },
14241429 at?: Date,
14251430 ): Result<LeadDetail> {
14261431 const found = leadFor(db, who, "update", input.id, {
@@ -1435,8 +1440,17 @@ function mark(
14351440 emit("lead.changed", { id: found.value.id });
14361441 return ok(detail(db, found.value.id)!);
14371442 }
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 }
14381452 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.");
14401454 const reason = reasonOf(input.reason);
14411455 if (input.state === "lost" && reason === undefined) return fail("invalid", REASON_HELP);
14421456 close(
@@ -1603,7 +1617,7 @@ export function tick(
16031617 export function turn(db: Database, at?: Date): number[] {
16041618 const now = stamp(at);
16051619 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])];
16071621 for (const id of leads) emit("lead.changed", { id });
16081622 return leads;
16091623 }
@@ -1953,6 +1967,8 @@ export interface Activity {
19531967 readonly older: number | undefined;
19541968 /** Leads lost in the last 30 days, by reason. */
19551969 readonly lost: readonly LostCount[];
1970 /** Deals closed in the last 30 days, by kind. */
1971 readonly deals: readonly DealCount[];
19561972 }
19571973
19581974 /** One page of the audit trail with the leads' names looked up, for the Activity page. */
@@ -1986,6 +2002,7 @@ export function activity(
19862002 older:
19872003 found.value.length > ACTIVITY_PAGE ? rows[rows.length - 1]!.id : undefined,
19882004 lost: lostCounts(db, Date.now()),
2005 deals: closedCounts(db, Date.now()),
19892006 });
19902007 }
19912008
src/audit.ts +2 −2
@@ -34,8 +34,8 @@ export interface AuditRow {
3434 readonly detail: string | null;
3535 }
3636
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;
3939 export type Act = (typeof ACTS)[number];
4040
4141 /** 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[] = [
312312 `UPDATE lead SET qualified_at = updated_at WHERE state = 'qualified'`,
313313 // A lead brought in from a spreadsheet: it has no inquiry clock and is not a lead in for the record or the report.
314314 `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`,
315320 ];
316321
317322 /**
src/drafts.ts +10 −1
@@ -67,6 +67,13 @@ export const CHECK_IN_DAY = 300;
6767 * `CHECK_IN_DAY`. It is a draft from the start, never part of the cadence.
6868 */
6969 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;
7077 export const isPriceChange = (day: number): boolean => day >= PRICE_CHANGE_DAY && day < CHECK_IN_DAY;
7178 export const isCheckIn = (day: number): boolean => day >= CHECK_IN_DAY && day < BEFORE_SHOWING_DAY;
7279 export const isBeforeShowing = (day: number): boolean =>
@@ -80,6 +87,8 @@ export const dayLabel = (day: number): string =>
8087 ? "First reply"
8188 : isAnswer(day)
8289 ? "Answer to their reply"
90 : isAnniversary(day)
91 ? "Home anniversary"
8392 : isPriceChange(day)
8493 ? "Price change"
8594 : isCheckIn(day)
@@ -92,7 +101,7 @@ export const dayLabel = (day: number): string =>
92101
93102 /** A message's short name in the follow-up plan: "Day 7", "Before the showing". */
94103 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}`;
96105
97106 export interface Asker {
98107 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
5656 expect(rows[0]).toEqual([...COLUMNS]);
5757 const all = await (await get("/leads")).json();
5858 expect(rows.length).toBe(1 + all.leads.length);
59 expect(all.leads.length).toBe(20);
59 expect(all.leads.length).toBe(21);
6060 expect(rows[1]!.length).toBe(COLUMNS.length);
6161 expect(["yes", "no"]).toContain(rows[1]![9]!);
6262 expect(["open", "qualified", "lost"]).toContain(rows[1]![4]!);
@@ -174,9 +174,11 @@ test("minutes to first reply, texts allowed, and last message", () => {
174174 "",
175175 "",
176176 "",
177 "",
178 "",
177179 ]);
178180 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", "", "", "", "", ""]);
180182 expect(csvName(new Date("2026-11-02T08:00:00Z"))).toBe("leads-2026-11-02.csv");
181183 });
182184
src/export.ts +4
@@ -22,6 +22,8 @@ export const COLUMNS = [
2222 "referred by",
2323 "referrer email",
2424 "referrer phone",
25 "closed",
26 "closed on",
2527 ] as const;
2628
2729 /** 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(
6769 lead.referred_by,
6870 lead.referrer_email,
6971 lead.referrer_phone,
72 lead.closedKind ?? "",
73 lead.closedOn ?? "",
7074 ]);
7175 return [COLUMNS, ...rows].map((row) => row.map((v, i) => field(v, COLUMNS[i] === "phone" || COLUMNS[i] === "referrer phone")).join(",")).join("\r\n") + "\r\n";
7276 }
src/leads.ts +74 −8
@@ -17,8 +17,10 @@ import {
1717 afterShowingDraft,
1818 BEFORE_SHOWING_DAY,
1919 CHECK_IN_DAY,
20 ANNIVERSARY_DAY,
2021 PRICE_CHANGE_DAY,
2122 checkInDraft,
23 isAnniversary,
2224 isCheckIn,
2325 isPriceChange,
2426 confirmationDraft,
@@ -42,8 +44,9 @@ import type { Agent } from "./env.ts";
4244 import { isQuiet, nextOpening } from "./quiet.ts";
4345 import { insertThanks, thanksOf, type Thanks } from "./thanks.ts";
4446 import { type LostReason } from "./lost.ts";
47 import { anniversaryDraft, anniversaryOf, MAX_YEARS, type ClosedKind } from "./closed.ts";
4548 import { followUpsFinished } from "./finished.ts";
46 import { morningOn } from "./putaway.ts";
49 import { morningOn, today } from "./putaway.ts";
4750 import { clockTime, dayName, holdUntil, morningAfter, morningBefore, type Showing } from "./showings.ts";
4851
4952 export type State = "new" | "contacted" | "qualified" | "lost";
@@ -83,6 +86,10 @@ export interface Lead {
8386 readonly referrer_phone: string;
8487 /** Brought in from a spreadsheet: no clock, no drafts, and not counted as a lead in (see `insertImported`). */
8588 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;
8693 }
8794
8895 export interface Message {
@@ -173,7 +180,8 @@ export const inView = (lead: DeskLead, view: View): boolean => {
173180 const closed = CLOSED.includes(lead.state);
174181 switch (view) {
175182 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));
177185 case "open":
178186 return !closed && lead.ready === null && lead.back_on === null;
179187 case "closed":
@@ -234,7 +242,9 @@ export type NewLead = {
234242 readonly referrer_phone?: string;
235243 };
236244
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;
238248 consent: number;
239249 imported: number;
240250 starred: number;
@@ -244,8 +254,10 @@ type LeadRecord = Omit<Lead, "consent" | "noteUpdatedAt" | "lostReason" | "lostA
244254 /** When it became qualified (see report.ts); not part of a Lead. */
245255 qualified_at: string | null;
246256 };
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 => ({
248258 ...row,
259 closedKind: closed_kind,
260 closedOn: closed_on,
249261 consent: row.consent === 1,
250262 starred: row.starred === 1,
251263 imported: row.imported === 1,
@@ -969,6 +981,7 @@ export function redraftUnsent(db: Database, agent: Agent): void {
969981
970982 /** The words for an unsent touch or showing note: the cadence's, or the note for the lead's showing as it stands. */
971983 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 ?? "");
972985 if (isCheckIn(day)) return checkInDraft(found);
973986 if (isPriceChange(day)) return priceChangeWords(db, found, day);
974987 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
10681081 }
10691082
10701083 /** 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 };
10721087
10731088 /** Qualified or lost (with the reason): the lead leaves the cadence, and nothing more is drafted to go. */
10741089 export function close(db: Database, id: number, closing: Closing, at: string): void {
10751090 db.transaction(() => {
10761091 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",
10781093 ).run(
10791094 closing.state,
10801095 at,
10811096 id,
10821097 closing.state === "lost" ? closing.reason : null,
10831098 closing.state === "lost" ? at : null,
1099 closing.state === "qualified" ? (closing.deal?.kind ?? null) : null,
1100 closing.state === "qualified" ? (closing.deal?.on ?? null) : null,
10841101 );
1102 db.query(`DELETE FROM message WHERE lead_id = ?1 AND ${ANNIVERSARIES} AND status IN ${UNSENT_SQL}`).run(id);
10851103 db.query(
10861104 "UPDATE message SET status = 'skipped', goes_at = NULL WHERE lead_id = ?1 AND status IN ('draft', 'scheduled', 'due', 'held')",
10871105 ).run(id);
10881106 })();
10891107 }
10901108
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
10911154 /**
10921155 * A closed lead goes back in follow-ups: `contacted` if a first reply was
10931156 * 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
10981161 db.transaction(() => {
10991162 const replied = found.replied_at !== null;
11001163 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",
11021165 ).run(
11031166 replied ? "contacted" : "new",
11041167 at,
@@ -1107,7 +1170,7 @@ export function reopen(db: Database, found: Lead, at: string, agent: Agent): voi
11071170 const channel = channelFor(found);
11081171 const skipped = db
11091172 .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)`,
11111174 )
11121175 .all(found.id, ANSWER_DAY, replied ? 1 : 0);
11131176 for (const m of skipped)
@@ -1125,6 +1188,7 @@ export function reopen(db: Database, found: Lead, at: string, agent: Agent): voi
11251188 db.query(
11261189 `DELETE FROM message WHERE lead_id = ?1 AND day >= ${CHECK_IN_DAY} AND day < ${AFTER_SHOWING_DAY} AND status = 'skipped'`,
11271190 ).run(found.id);
1191 db.query(`DELETE FROM message WHERE lead_id = ?1 AND ${ANNIVERSARIES} AND status IN ${UNSENT_SQL}`).run(found.id);
11281192 const showing = showingOf(db, found.id);
11291193 if (showing !== null)
11301194 confirmShowing(db, findLead(db, found.id)!, showing.at, showing.address, at, agent);
@@ -1229,6 +1293,8 @@ export function bookShowing(
12291293 /** 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). */
12301294 const UNSENT: readonly string[] = ["draft", "scheduled", "due", "skipped"];
12311295 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}`;
12321298
12331299 /**
12341300 * 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[] = [
353353 },
354354 {
355355 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.",
357357 inputSchema: {
358358 type: "object",
359359 properties: {
360360 id: { type: "integer" },
361 state: { enum: ["qualified", "lost", "open"] },
361 state: { enum: ["qualified", "lost", "closed", "open"] },
362362 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." },
363365 },
364366 required: ["id", "state"],
365367 },
366368 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 }),
368370 },
369371 {
370372 name: "edit_lead",
src/pages/activity.ts +13 −1
@@ -4,6 +4,7 @@
44 */
55
66 import type { LostCount } from "../lost.ts";
7 import { KIND_LABEL, type DealCount } from "../closed.ts";
78 import type { Activity } from "../actions.ts";
89 import type { AuditRow } from "../audit.ts";
910 import type { Agent } from "../env.ts";
@@ -31,6 +32,15 @@ function sentence(row: AuditRow, names: ReadonlyMap<number, string>, zone: strin
3132 return `<li class="activity-row">${text}${refused} <span class="muted">· <time datetime="${escape(row.at)}">${escape(when(row.at, zone))}</time></span></li>`;
3233 }
3334
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
3444 /** Leads lost in the last 30 days, counted by the reason the agent gave. */
3545 function lostBlock(lost: readonly LostCount[]): string {
3646 const items = lost
@@ -49,9 +59,10 @@ export function activityPage(input: {
4959 names: ReadonlyMap<number, string>;
5060 older: number | undefined;
5161 lost: readonly LostCount[];
62 deals?: readonly DealCount[];
5263 sentOnly: boolean;
5364 }): string {
54 const { who, agent, rows, names, older, lost, sentOnly } = input;
65 const { who, agent, rows, names, older, lost, deals = [], sentOnly } = input;
5566 const filter = sentOnly ? "&amp;show=sent" : "";
5667 const links = `<nav class="row" aria-label="Filter">
5768 <a class="button button-quiet" href="/activity"${sentOnly ? "" : ' aria-current="page"'}>Everything</a>
@@ -69,6 +80,7 @@ export function activityPage(input: {
6980 body: `<p><a class="backlink" href="/">← All leads</a></p>
7081 <h1>Activity</h1>
7182 <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)}
7284 ${lostBlock(lost)}
7385 ${links}
7486 ${list}
src/pages/lead.ts +6 −1
@@ -20,6 +20,7 @@ import type { Who } from "../who.ts";
2020 import { layout } from "./layout.ts";
2121 import { FINISHED_ACTION, FINISHED_LABEL } from "../finished.ts";
2222 import { LOST_REASONS, REASON_LABEL, lostSaid } from "../lost.ts";
23 import { CLOSED_KINDS, closedSaid } from "../closed.ts";
2324 import { clock, consent, day, source, starForm, STATE_LABEL, when } from "./parts.ts";
2425
2526 /** One line of the thread: something we sent, or something they wrote back. */
@@ -99,7 +100,7 @@ export function leadPage(input: {
99100 closed
100101 ? lead.state === "lost"
101102 ? `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."}`
103104 : lead.messages.some((m) => m.status === "scheduled")
104105 ? "The next follow-up is drafted below and comes due on its day."
105106 : lead.finished
@@ -216,6 +217,10 @@ export function leadPage(input: {
216217 <form method="post" action="${path}/state"><input type="hidden" name="state" value="lost">
217218 <label>Why? <select name="reason" required>${LOST_REASONS.map((r) => `<option value="${r}">${escape(REASON_LABEL[r])}</option>`).join("")}</select></label>
218219 <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>
219224 </section>`
220225 : mayUpdate
221226 ? `<section class="states">
src/routes.ts +27 −7
@@ -89,6 +89,7 @@ import { MAX_EMAIL_TEXT } from "./intake.ts";
8989 import { MAX_SIGNIN_TEXT } from "./signins.ts";
9090 import { MAX_CSV, MAX_ROWS } from "./import.ts";
9191 import { LOST_REASONS, lostSaid } from "./lost.ts";
92 import { CLOSED_KINDS, closedSaid } from "./closed.ts";
9293 import type { ImportPreview } from "./actions.ts";
9394 import { findLead, MAX_INBOUND, MAX_LEAD_NOTE, MAX_QUERY, Q_HELP, showingsAhead, VIEW_HELP, VIEWS, type LeadDetail, type NewLead } from "./leads.ts";
9495 import { dueSaid } from "./sentences.ts";
@@ -197,6 +198,15 @@ const LEAD_FIELDS = {
197198 anyOf: [{ enum: [...LOST_REASONS] }, { type: "null" }],
198199 description: "Why the lead was lost; null unless it is lost, and for a lead lost before reasons were asked for.",
199200 },
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 },
200210 lostAt: {
201211 type: ["string", "null"],
202212 format: "date-time",
@@ -756,6 +766,16 @@ const REASON_FIELD = {
756766 description: "Why the lead is lost; required when state is lost.",
757767 } as const;
758768
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
759779 const BACK_ON_FIELD = {
760780 type: ["string", "null"],
761781 description: "YYYY-MM-DD: put the lead away until then; null brings it back now.",
@@ -1301,14 +1321,14 @@ export const routes: readonly Route[] = [
13011321 if (noted) return answer(setNote(c.db, c.who, { id: ID(c), note: input.note }));
13021322 if (details)
13031323 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 }));
13051325 },
13061326 api: {
13071327 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.",
13091329 body: {
13101330 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 },
13121332 },
13131333 answer: LEAD,
13141334 },
@@ -1605,17 +1625,17 @@ export const routes: readonly Route[] = [
16051625 handle: (c) =>
16061626 posted(
16071627 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 }),
16091629 (lead) => ({
16101630 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." }),
16121632 }),
16131633 ),
16141634 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.",
16161636 body: {
16171637 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 },
16191639 required: ["state"],
16201640 },
16211641 answer: LEAD,
src/sample.ts +13
@@ -12,8 +12,10 @@ import type { Source } from "./drafts.ts";
1212 import { morningAfter, wallAt } from "./showings.ts";
1313 import { currentAgent } from "./profile.ts";
1414 import { today } from "./putaway.ts";
15 import type { ClosedKind } from "./closed.ts";
1516 import {
1617 advance,
18 anniversaries,
1719 bookShowing,
1820 putAway,
1921 close,
@@ -34,6 +36,8 @@ interface Sample extends NewLead {
3436 /** The cadence days already sent. */
3537 readonly touched?: readonly number[];
3638 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 };
3741 /** A showing already held, this long ago (rounded to the agent's morning after it). */
3842 readonly showedAgo?: number;
3943 /** 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[] = [
96100 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" } }),
97101 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" } }),
98102 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" } }),
99104 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] }),
100105 ];
101106
@@ -135,8 +140,16 @@ export function seed(db: Database, now: Date): number {
135140 if (sample.note !== undefined)
136141 writeNote(db, findLead(db, id)!, sample.note, iso(asked + 30 * M));
137142 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 }
138150 }
139151 advance(db, iso(t));
152 anniversaries(db, t, currentAgent(db));
140153 return SAMPLES.length;
141154 }
142155
src/search.test.ts +2 −2
@@ -37,8 +37,8 @@ test("a name, an email or a phone finds the lead, in any case", () => {
3737 });
3838
3939 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);
4242 });
4343
4444 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
3333 return label === undefined ? "" : ` (via ${esc(label)})`;
3434 }
3535
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
3648 /** A put-away's date: " until Mar 1, 2027"; "" when the row names none. */
3749 function awayWords(detail: string | null, esc: (text: string) => string): string {
3850 try {
@@ -165,6 +177,13 @@ export function deed(
165177 done: `marked ${lead} ${row.act}`,
166178 tried: `mark ${lead} ${row.act}`,
167179 };
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 }
168187 if (row.act === "inbound")
169188 return {
170189 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
416416 const t = terminal(db, alice);
417417 t.key("x");
418418 const screen = read(t);
419 expect(screen).toContain("20 leads as CSV");
419 expect(screen).toContain("21 leads as CSV");
420420 expect(screen).toContain("name,email,phone,source,status");
421421 expect(screen).toContain("Maria Gonzalez");
422422 t.key("\x1b");
src/terminal.ts +38 −6
@@ -9,7 +9,7 @@
99 * due j/k move · space tick · s send the ticked ones (each shown in full) · esc back
1010
1111 * 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
1313 * edit type; enter is a new line · ctrl-d approve and send · esc cancel
1414 * wrote type what they wrote · ctrl-t text/email (text only with consent) · ctrl-d record it and draft the answer · esc cancel
1515 * 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";
8585 import { REPORT_ROWS } from "./report-words.ts";
8686 import { goesLabel, isQuiet, nextOpening, RECORD_NOTE } from "./quiet.ts";
8787 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";
8989 import { ssh, title, where } from "./env.ts";
9090 import { FINISHED_ACTION, FINISHED_LABEL } from "./finished.ts";
9191 import { LOST_REASONS, REASON_LABEL, lostSaid } from "./lost.ts";
92 import { CLOSED_KINDS, closedSaid, type ClosedKind } from "./closed.ts";
9293 import { answerSince, CLOSED, MAX_QUERY, VIEWS, queryOf, type DeskLead, type LeadDetail, type Message, type View as DeskView } from "./leads.ts";
9394 import { day as dayOn, when } from "./pages/parts.ts";
9495 import { currentAgent } from "./profile.ts";
@@ -206,6 +207,7 @@ type View =
206207 | { name: "wrote"; id: number; text: string; channel: Channel }
207208 | { name: "note"; id: number; text: string }
208209 | { name: "lost"; id: number }
210 | { name: "deal"; id: number; kind: ClosedKind | null; typed: string }
209211 | { name: "details"; id: number; step: number; typed: string; fields: Record<string, string> }
210212 | { name: "paste"; text: string }
211213 | { name: "import"; text: string; preview?: ImportPreview }
@@ -325,12 +327,12 @@ export function terminal(
325327 l.goes_at !== null
326328 ? dim(goesLabel(l.goes_at, now(), zone))
327329 : 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 : "")
329331 : l.ready.day === 0
330332 ? "reply drafted"
331333 : isAnswer(l.ready.day)
332334 ? "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`;
334336 const name = `${l.starred ? "★ " : ""}${clean(l.name)}`.padEnd(18).slice(0, 18);
335337 const showing =
336338 l.showing === null
@@ -457,7 +459,7 @@ export function terminal(
457459 lines.push(
458460 dim(
459461 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.`
461463 : next !== undefined
462464 ? `Nothing to send now. ${titleOf(next)} comes due ${next.due_at.slice(0, 10)}.`
463465 : l.finished
@@ -528,7 +530,7 @@ export function terminal(
528530 ...(l.back_on === null ? [] : ["A bring back now"]),
529531 ...(l.state === "qualified" || l.state === "lost"
530532 ? ["o put back in follow-ups"]
531 : ["q qualified", "x lost"]),
533 : ["q qualified", "y closed: bought or sold", "x lost"]),
532534 ...(l.finished && mayEdit(l) ? [`m ${FINISHED_ACTION.toLowerCase()}`] : []),
533535 ...(mayEdit(l) ? ["d fix details"] : []),
534536 ...(mayNote(l) ? [l.note === "" ? "n add a note" : "n edit your note"] : []),
@@ -817,6 +819,16 @@ export function terminal(
817819 "",
818820 dim("type a note · enter new line · ctrl-o outcome · ctrl-d log the call · esc cancel"),
819821 ]
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 ]
820832 : view.name === "lost"
821833 ? [
822834 bold(`Why was ${clean(lead(view.id)?.name ?? "the lead")} lost?`),
@@ -1033,6 +1045,8 @@ export function terminal(
10331045 said = marked.ok ? `${marked.value.name}: lost, ${REASON_LABEL.never_answered}.` : marked.message;
10341046 } else if (input === "x" && !CLOSED.includes(l.state))
10351047 view = { name: "lost", id };
1048 else if (input === "y" && !CLOSED.includes(l.state))
1049 view = { name: "deal", id, kind: null, typed: "" };
10361050 else if (input === "c" && l.phone !== "")
10371051 view = { name: "call", id, outcome: "talked", text: "" };
10381052 else if (input === "b" && !CLOSED.includes(l.state))
@@ -1179,6 +1193,24 @@ export function terminal(
11791193 }
11801194 return;
11811195 }
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 }
11821214 case "lost": {
11831215 const reason = LOST_REASONS[Number(input) - 1];
11841216 if (reason !== undefined && /^[1-9]$/.test(input)) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs