Public: anyone can read this; the crew can change it.
An open deal nothing has touched in 14 days says so on the clock #313
mason/deal-clock:mason/310-an-open-deal-nothing-has-touched-in-14-d → main 4 commits fork
38 files
changed +208
−13
.bash_profile added
No content changes.
.bashrc added
No content changes.
.claude/agents added
No content changes.
.claude/commands added
No content changes.
.claude/hooks added
No content changes.
.claude/launch.json added
No content changes.
.claude/loop.md added
No content changes.
.claude/output-styles added
No content changes.
.claude/routines added
No content changes.
.claude/scheduled_tasks.json added
No content changes.
.claude/settings.json added
No content changes.
.claude/settings.local.json added
No content changes.
.claude/skills added
No content changes.
.claude/workflows added
No content changes.
.gitconfig added
No content changes.
.gitmodules added
No content changes.
.idea added
No content changes.
.mcp.json added
No content changes.
.profile added
No content changes.
.ripgreprc added
No content changes.
.vscode added
No content changes.
.zprofile added
No content changes.
.zshrc added
No content changes.
README.md +1 −1
| @@ -34,7 +34,7 @@ with a link to the page it names. In the terminal press `G` for the same steps. | ||
| 34 | 34 | adds up your book, like "4 open · $1,693,400 under contract · 2 close in |
| 35 | 35 | November · 3 closed this year, $1,375,000 · $45,328 commission pending · |
| 36 | 36 | $37,395 earned this year" (the November count follows the month you open it |
| 37 | in). Below the clock, **This week** lists every open date that is overdue or | |
| 37 | in). A lane that says **Quiet 16 days** beside its address (2210 Juniper Court) is an open deal nothing has touched for two weeks and with no date due in the next three; the deal page says when it was last touched. Below the clock, **This week** lists every open date that is overdue or | |
| 38 | 38 | due in the next seven days, with who owes it. |
| 39 | 39 | 3. **Open a deal.** Tap the red inspection marker on 77 Harbor View Dr. A |
| 40 | 40 | sheet opens with the date, who owes it (the buyer), what is owed, and the |
docs/TECHNICAL.md +1
| @@ -283,6 +283,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 283 | 283 | | `src/money.ts` | the sale price and earnest amount in whole dollars: `amount` validates input, `dollars` formats it one way; called by deals, book, reminders, MCP, routes and the pages | |
| 284 | 284 | | `src/profile.ts` | the agent's saved details (one `profile` row, the environment as defaults): `agentOf`, `zoneOf`, `getProfile`, `setProfile`, `useDefaults`, with `permit` on the writes (the `voice`, warm or brief, is one of its fields and the drafting in `src/reminders.ts` reads it and the two voices' words live in `src/wording.ts`; `src/voice.ts` renders the Profile page's sample of each); called by deals, MCP, routes, sample, the terminal and the Profile page | |
| 285 | 285 | | `src/progress.ts` | how far along a deal is: a deal and a day in, dates done of total, overdue count and days to closing out; called by routes and the clock, deal and progress pages | |
| 286 | | `src/quiet.ts` | which open deals have gone quiet: a deal and a day in, days since the last touch out, or null when closed, recently touched, or a date is due within 3 days; the last touch (`DealView.touched`) is derived in `src/deals.ts` from dates done, waived or moved, messages sent or declined, and the audit trail, so there is no migration; called by `src/pages/quiet.ts` for the clock lane badge and the deal page line | | |
| 286 | 287 | | `src/settled.ts` | what the agent settled today, counted from the records: a database, zone and day in, sent, declined and held counts out; called by routes and the review page | |
| 287 | 288 | | `src/shift.ts` | "Closing moved": a deal and a new closing date in, the open non-custom dates that would shift by the same days out, rolled to business days; confirming goes through `amendDeal` | |
| 288 | 289 | | `static/app.css`, `static/live.js`, `static/sheets.js` | the look in the tokens' words, the clock that stays current, and a date's sheet fetched when tapped | |
docs/design/areas/clock.md +2
| @@ -7,3 +7,5 @@ New classes: | ||
| 7 | 7 | - `.progress` — the paragraph that holds both at the top of a deal's page. |
| 8 | 8 | |
| 9 | 9 | - `.closing-clash` — one amber line (`--color-soon`) under a closing on This week, the print sheet and the deal page: "Also closing at 2:30 pm: 2210 Juniper Court", the address linked to its deal. |
| 10 | ||
| 11 | - `.quiet-badge` — the word pill "Quiet 16 days" (amber border and text, `--color-soon`) beside an open deal's address on its lane and above its dates on its page: shown when nothing has touched the deal for 14 days and no date is due within 3. A word, so colour is never the only signal. |
src/db.ts +2
| @@ -590,6 +590,8 @@ export const migrations: string[] = [ | ||
| 590 | 590 | `ALTER TABLE deal ADD COLUMN note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 500)`, |
| 591 | 591 | // How new drafts sound: warm (the original wording) or brief (src/reminders.ts). |
| 592 | 592 | `ALTER TABLE profile ADD COLUMN voice TEXT NOT NULL DEFAULT 'warm' CHECK (voice IN ('warm', 'brief'))`, |
| 593 | // Reading when a deal was last touched asks the trail by target (src/quiet.ts); without this every read of the clock scans the whole trail. | |
| 594 | `CREATE INDEX IF NOT EXISTS audit_target ON audit (resource, target, at)`, | |
| 593 | 595 | ]; |
| 594 | 596 | |
| 595 | 597 | export function openDb(dir = dataDir): Database { |
src/deals.ts +38 −3
| @@ -227,6 +227,8 @@ export interface DealView extends Deal { | ||
| 227 | 227 | readonly conflicts: readonly Conflict[]; |
| 228 | 228 | /** Other open deals of this agent closing the same day less than two hours from this one (`overlap.ts`). */ |
| 229 | 229 | readonly closingConflicts: readonly ClosingConflict[]; |
| 230 | /** The day anything last happened on the deal: opened, a date done, waived or moved, a message sent or declined, or any change the audit trail records (`quiet.ts`). */ | |
| 231 | readonly touched: string; | |
| 230 | 232 | } |
| 231 | 233 | |
| 232 | 234 | export type Moment = "before" | "day_of" | "late" | "moved" | "status" | "checkin" | "anniversary"; |
| @@ -320,8 +322,22 @@ function withHref(m: SentRow, parties: readonly Party[], zone: string): Sent { | ||
| 320 | 322 | const NO_PAPERS: readonly Paper[] = []; |
| 321 | 323 | |
| 322 | 324 | /** One deal assembled from its rows. */ |
| 323 | function assemble(deal: Deal, rows: Children, zone: string): DealView { | |
| 325 | function assemble(deal: Deal, rows: Children, zone: string, audited: string | null): DealView { | |
| 324 | 326 | const moves = rows.moves; |
| 327 | // Stamps are ISO and in UTC, so the latest sorts last: only that one is turned into a day in the agent's zone. | |
| 328 | let latest = deal.created_at; | |
| 329 | const later = (at: string | null): void => { | |
| 330 | if (at !== null && at > latest) latest = at; | |
| 331 | }; | |
| 332 | later(audited); | |
| 333 | for (const d of rows.deadlines) { | |
| 334 | later(d.done_at); | |
| 335 | later(d.waived_at); | |
| 336 | } | |
| 337 | for (const m of moves) later(m.at); | |
| 338 | for (const m of rows.sent) later(m.sent_at); | |
| 339 | for (const m of rows.declined) later(m.at); | |
| 340 | const touched = dayOfStamp(zone, latest); | |
| 325 | 341 | const papers = rows.papers.length === 0 ? undefined : byDeal(rows.papers, (p) => p.deadline_id); |
| 326 | 342 | return { |
| 327 | 343 | ...deal, |
| @@ -346,6 +362,7 @@ function assemble(deal: Deal, rows: Children, zone: string): DealView { | ||
| 346 | 362 | held: rows.held, |
| 347 | 363 | conflicts: conflicts(rows.deadlines), |
| 348 | 364 | closingConflicts: [], |
| 365 | touched, | |
| 349 | 366 | }; |
| 350 | 367 | } |
| 351 | 368 | |
| @@ -397,7 +414,19 @@ function view(db: Database, deal: Deal): DealView { | ||
| 397 | 414 | ) |
| 398 | 415 | .all(deal.id), |
| 399 | 416 | held: db.query<Held, [number]>("SELECT * FROM reminder_hold WHERE deal_id = ?1 ORDER BY id").all(deal.id), |
| 400 | }, zoneOf(db)); | |
| 417 | }, zoneOf(db), lastAudited(db, deal.id)); | |
| 418 | } | |
| 419 | ||
| 420 | /** The audit rows that are a change made to a deal: allowed, and done. */ | |
| 421 | const AUDITED = "resource = 'deal' AND allowed = 1 AND outcome = 'ok'"; | |
| 422 | ||
| 423 | /** When the audit trail last recorded a change to this deal; null when it recorded none, or cannot be read (a read never fails for the trail). */ | |
| 424 | function lastAudited(db: Database, dealId: number): string | null { | |
| 425 | try { | |
| 426 | return db.query<{ at: string | null }, [number]>(`SELECT max(at) AS at FROM audit WHERE ${AUDITED} AND target = ?1`).get(dealId)?.at ?? null; | |
| 427 | } catch { | |
| 428 | return null; | |
| 429 | } | |
| 401 | 430 | } |
| 402 | 431 | |
| 403 | 432 | /** Rows grouped by the deal they hang off, in the order read. */ |
| @@ -460,6 +489,12 @@ function viewMany(db: Database, where: string, params: string[]): DealView[] { | ||
| 460 | 489 | (r) => r.deal_id, |
| 461 | 490 | ); |
| 462 | 491 | const zone = zoneOf(db); |
| 492 | const audited = new Map( | |
| 493 | db | |
| 494 | .query<{ target: string; at: string }, string[]>(`SELECT target, max(at) AS at FROM audit WHERE ${AUDITED} AND target IN (SELECT CAST(id AS TEXT) FROM deal${where}) GROUP BY target`) | |
| 495 | .all(...params) | |
| 496 | .map((r) => [Number(r.target), r.at] as const), | |
| 497 | ); | |
| 463 | 498 | return deals.map((deal) => |
| 464 | 499 | assemble(deal, { |
| 465 | 500 | deadlines: deadlines.get(deal.id) ?? [], |
| @@ -469,7 +504,7 @@ function viewMany(db: Database, where: string, params: string[]): DealView[] { | ||
| 469 | 504 | sent: sent.get(deal.id) ?? [], |
| 470 | 505 | declined: declined.get(deal.id) ?? [], |
| 471 | 506 | held: held.get(deal.id) ?? [], |
| 472 | }, zone), | |
| 507 | }, zone, audited.get(deal.id) ?? null), | |
| 473 | 508 | ); |
| 474 | 509 | } |
| 475 | 510 |
src/migrations.pin.json +3 −1
| @@ -62,5 +62,7 @@ | ||
| 62 | 62 | "8d0b71567ca7fc8e0927bfb1465c84a30b6702a8460145f7640e612b829530f5", |
| 63 | 63 | "c82fe9b3aea33d99a2c65ffb4179901d5aecde6b2ef759531b94ff12035a719a", |
| 64 | 64 | "d7c7a79baf6dcd397af5c223839a821db0f0b744f6f02358ea4ba38532ae148b", |
| 65 | "9d29fe2875d6dff02bf7b79a6afbea525f86bca59756a28378e979916a8cfc27" | |
| 65 | "9d29fe2875d6dff02bf7b79a6afbea525f86bca59756a28378e979916a8cfc27", | |
| 66 | "6c8473e560e240dcc74e0e01f198acd6fc1f33a1fc19e80c17fbfae097ee1caf", | |
| 67 | "73ce6502f90acf67bfdb43e658f1ea0fdd15d29ca378c6d470743c571e83bdbc" | |
| 66 | 68 | ] |
src/migrations.test.ts +1 −1
| @@ -13,7 +13,7 @@ test("every migration is pinned by position; a pull may only append", () => { | ||
| 13 | 13 | }); |
| 14 | 14 | |
| 15 | 15 | test("a database made before the Profile line gets the column and reads the agent", () => { |
| 16 | const before = migrations.filter((m) => !/profile ADD COLUMN line|commission_rate|profile ADD COLUMN zone|ADD COLUMN cash|'anniversary'|deadline ADD COLUMN (time|place)|party ADD COLUMN reach|reminder_hold ADD COLUMN back_on|TABLE IF NOT EXISTS paper|deal ADD COLUMN note|deadline_move ADD COLUMN undo|profile ADD COLUMN voice/.test(m)); | |
| 16 | const before = migrations.filter((m) => !/profile ADD COLUMN line|commission_rate|profile ADD COLUMN zone|ADD COLUMN cash|'anniversary'|deadline ADD COLUMN (time|place)|party ADD COLUMN reach|reminder_hold ADD COLUMN back_on|TABLE IF NOT EXISTS paper|deal ADD COLUMN note|deadline_move ADD COLUMN undo|profile ADD COLUMN voice|INDEX IF NOT EXISTS audit_target/.test(m)); | |
| 17 | 17 | const db = new Database(":memory:"); |
| 18 | 18 | db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"); |
| 19 | 19 | before.forEach((sql, i) => { |
src/pages/clock.ts +2 −1
| @@ -26,6 +26,7 @@ import { bookLine, tally } from "../book.ts"; | ||
| 26 | 26 | import { closedInLine, daysToClose, paceLine, paceOf } from "../pace.ts"; |
| 27 | 27 | import { dollars } from "../money.ts"; |
| 28 | 28 | import { progressLine } from "./progress.ts"; |
| 29 | import { quietBadge } from "./quiet.ts"; | |
| 29 | 30 | |
| 30 | 31 | /** The days the clock shows: a week behind today (further, if something open is older), to a few days past the last closing. */ |
| 31 | 32 | export function span(deals: readonly DealView[], on: string): { start: string; days: number } { |
| @@ -80,7 +81,7 @@ function drawLane(deal: DealView, start: string, on: string): string { | ||
| 80 | 81 | }) |
| 81 | 82 | .join(""); |
| 82 | 83 | return `<li class="lane"> |
| 83 | <div class="lane-head"><a href="/deals/${deal.id}">${escape(deal.address)}</a> <small class="muted">${dealSideLabel(deal)} · ${deal.conflicts.length === 0 ? "" : `<b class="u-overdue">${deal.conflicts.length} out of order</b> · `}${progressLine(deal, on)}</small></div> | |
| 84 | <div class="lane-head"><a href="/deals/${deal.id}">${escape(deal.address)}</a> ${quietBadge(deal, on)}<small class="muted">${dealSideLabel(deal)} · ${deal.conflicts.length === 0 ? "" : `<b class="u-overdue">${deal.conflicts.length} out of order</b> · `}${progressLine(deal, on)}</small></div> | |
| 84 | 85 | <div class="track" style="height:calc(${height} * 3rem)">${markers}</div> |
| 85 | 86 | </li>`; |
| 86 | 87 | } |
src/pages/deal.ts +2
| @@ -24,6 +24,7 @@ import { sideOf } from "../side.ts"; | ||
| 24 | 24 | import { dealSideLabel, sideLabel, sideSelect } from "./side.ts"; |
| 25 | 25 | import { papersLine } from "../papers.ts"; |
| 26 | 26 | import { progressLine } from "./progress.ts"; |
| 27 | import { touchedNote } from "./quiet.ts"; | |
| 27 | 28 | import { appointmentForm, appointmentText, type AppointmentProblem } from "./appointment.ts"; |
| 28 | 29 | import { badge, conflictLines, nonBusinessLine, noteLine, owesChangedLine, rollForm, undoMoveForm, draftItem, dueLine, outboxLine, owedBy, releaseForm, sheet, sheetId } from "./sheet.ts"; |
| 29 | 30 | |
| @@ -398,6 +399,7 @@ export function dealPage(input: { | ||
| 398 | 399 | </section> |
| 399 | 400 | <div id="deal" data-live="deal.changed"> |
| 400 | 401 | <p class="progress">${progressLine(deal, on)}</p> |
| 402 | ${touchedNote(deal, on)} | |
| 401 | 403 | <section class="card"> |
| 402 | 404 | <h2>Dates</h2> |
| 403 | 405 | ${conflictLines(deal)} |
src/pages/quiet.ts added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | /** The quiet badge on a deal's lane, and the line on its page that says when it was last touched. */ | |
| 2 | ||
| 3 | import { type DealView } from "../deals.ts"; | |
| 4 | import { escape } from "../http.ts"; | |
| 5 | import { quietDays, quietLabel, touchedLine } from "../quiet.ts"; | |
| 6 | ||
| 7 | /** "Quiet 16 days" in a pill when the open deal has gone quiet, then a space; nothing otherwise. */ | |
| 8 | export function quietBadge(deal: DealView, on: string): string { | |
| 9 | const days = quietDays(deal, on); | |
| 10 | return days === null ? "" : `<b class="quiet-badge">${escape(quietLabel(days))}</b> `; | |
| 11 | } | |
| 12 | ||
| 13 | /** When an open deal was last touched, with the badge if it is quiet; nothing for a closed or fallen-through deal. */ | |
| 14 | export function touchedNote(deal: DealView, on: string): string { | |
| 15 | if (deal.closed || deal.fell_through) return ""; | |
| 16 | return `<p class="small muted">${quietBadge(deal, on)}${escape(touchedLine(deal.touched, on))}</p>`; | |
| 17 | } |
src/quiet.test.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** An open deal nothing has touched in 14 days says so on the clock; a date due soon, or a closed deal, never does. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { addDays, DEFAULT_ZONE, today } from "./dates.ts"; | |
| 7 | import { openDb } from "./db.ts"; | |
| 8 | import { createDeal, getDeal, listDeals, markDone, setDealNote } from "./deals.ts"; | |
| 9 | import { clock } from "./pages/clock.ts"; | |
| 10 | import { dealPage } from "./pages/deal.ts"; | |
| 11 | import { quietDays, QUIET_DAYS } from "./quiet.ts"; | |
| 12 | import type { Who } from "./who.ts"; | |
| 13 | ||
| 14 | const on = today(DEFAULT_ZONE); | |
| 15 | const alice: Who = { role: "user", id: "u1", name: "alice" }; | |
| 16 | const dl = (due: string, open = true) => ({ done_at: open ? null : "x", waived_at: null, due }) as never; | |
| 17 | const deal = (touched: string, deadlines: unknown[] = [dl(addDays(on, 20))], over: object = {}) => ({ closed: false, fell_through: false, touched, deadlines, ...over }) as never; | |
| 18 | ||
| 19 | test("quiet at 14 days and not at 13", () => { | |
| 20 | expect(QUIET_DAYS).toBe(14); | |
| 21 | expect(quietDays(deal(addDays(on, -14)), on)).toBe(14); | |
| 22 | expect(quietDays(deal(addDays(on, -16)), on)).toBe(16); | |
| 23 | expect(quietDays(deal(addDays(on, -13)), on)).toBeNull(); | |
| 24 | }); | |
| 25 | ||
| 26 | test("a date due inside 3 days exempts the deal; a date 4 days out, or a done one, does not", () => { | |
| 27 | const old = addDays(on, -30); | |
| 28 | expect(quietDays(deal(old, [dl(addDays(on, 3))]), on)).toBeNull(); | |
| 29 | expect(quietDays(deal(old, [dl(on)]), on)).toBeNull(); | |
| 30 | expect(quietDays(deal(old, [dl(addDays(on, 4))]), on)).toBe(30); | |
| 31 | expect(quietDays(deal(old, [dl(addDays(on, 1), false)]), on)).toBe(30); | |
| 32 | }); | |
| 33 | ||
| 34 | test("a closed or fallen-through deal is never quiet", () => { | |
| 35 | const old = addDays(on, -30); | |
| 36 | expect(quietDays(deal(old, [], { closed: true }), on)).toBeNull(); | |
| 37 | expect(quietDays(deal(old, [], { fell_through: true }), on)).toBeNull(); | |
| 38 | }); | |
| 39 | ||
| 40 | test("the last touch is read from the records: a deal opened long ago goes quiet, a note or a done date wakes it", () => { | |
| 41 | const db = openDb(mkdtempSync(join(tmpdir(), "app-quiet-"))); | |
| 42 | const made = createDeal(db, alice, { openAnyway: true, address: "5 Quiet Way", buyer: "A Buyer", seller: "A Seller", contractDate: addDays(on, -20), closingDate: addDays(on, 60) }); | |
| 43 | if (!made.ok) throw new Error(made.message); | |
| 44 | const id = made.value.id; | |
| 45 | const view = () => { const g = getDeal(db, alice, { id }); if (!g.ok) throw new Error(g.message); return g.value; }; | |
| 46 | expect(view().touched).toBe(on); | |
| 47 | const old = `${addDays(on, -16)}T09:00:00.000Z`; | |
| 48 | db.query("UPDATE deal SET created_at = ?1 WHERE id = ?2").run(old, id); | |
| 49 | db.query("DELETE FROM audit").run(); | |
| 50 | db.query("UPDATE deadline SET due = ?1 WHERE deal_id = ?2 AND kind <> 'closing'").run(addDays(on, 30), id); | |
| 51 | expect(view().touched).toBe(addDays(on, -16)); | |
| 52 | const html = (v = view()) => clock({ who: alice, deals: [v], on }); | |
| 53 | expect(html()).toContain("Quiet 16 days"); | |
| 54 | expect(dealPage({ who: alice, deal: view(), on } as never)).toContain("Last touched 16 days ago"); | |
| 55 | // A private note on the deal is an audited change: the deal is touched today. | |
| 56 | expect(setDealNote(db, alice, { id, note: "called the lender" }).ok).toBe(true); | |
| 57 | expect(view().touched).toBe(on); | |
| 58 | expect(html()).not.toContain("quiet-badge"); | |
| 59 | // A date marked done is a touch too. | |
| 60 | db.query("DELETE FROM audit").run(); | |
| 61 | expect(view().touched).toBe(addDays(on, -16)); | |
| 62 | const first = view().deadlines.find((d) => d.kind === "earnest")!; | |
| 63 | expect(markDone(db, alice, { id: first.id, done: true }).ok).toBe(true); | |
| 64 | db.query("DELETE FROM audit").run(); | |
| 65 | expect(view().touched).toBe(on); | |
| 66 | }); | |
| 67 | ||
| 68 | test("the sample deals on a fresh boot show one quiet deal on the clock, and none of the closed ones", () => { | |
| 69 | const db = openDb(mkdtempSync(join(tmpdir(), "app-quiet-sample-"))); | |
| 70 | const l = listDeals(db, alice); | |
| 71 | if (!l.ok) throw new Error(l.message); | |
| 72 | const html = clock({ who: alice, deals: l.value, on }); | |
| 73 | expect(html.match(/class="quiet-badge"/g)?.length).toBe(1); | |
| 74 | expect(html).toMatch(/2210 Juniper Court<\/a> <b class="quiet-badge">Quiet 1[5-7] days<\/b>/); | |
| 75 | const juniper = l.value.find((d) => d.address === "2210 Juniper Court")!; | |
| 76 | expect(quietDays(juniper, on)).toBeGreaterThanOrEqual(14); | |
| 77 | expect(dealPage({ who: alice, deal: juniper, on } as never)).toContain("Last touched 1"); | |
| 78 | const closed = l.value.find((d) => d.closed)!; | |
| 79 | expect(dealPage({ who: alice, deal: closed, on } as never)).not.toContain("Last touched"); | |
| 80 | }); |
src/quiet.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * Which open deals have gone quiet: nothing has touched them for a while and | |
| 3 | * no date is about to force the matter. Plain data in, plain data out; the | |
| 4 | * clock and the deal page read it from here. | |
| 5 | */ | |
| 6 | ||
| 7 | import { daysBetween } from "./dates.ts"; | |
| 8 | import { isOpen } from "./deadlines.ts"; | |
| 9 | import type { DealView } from "./deals.ts"; | |
| 10 | ||
| 11 | /** Whole days without a touch after which an open deal is quiet. */ | |
| 12 | export const QUIET_DAYS = 14; | |
| 13 | /** A deal with an open date due today or within this many days is never quiet: the date is the nudge. An overdue date is not an exemption: the clock already flags it, and a deal nobody has touched since is the one a client will ask about. */ | |
| 14 | export const SOON_DAYS = 3; | |
| 15 | ||
| 16 | /** How many days an open deal has been quiet for, or null when it is not quiet (closed, fell through, recently touched, or a date is due soon). */ | |
| 17 | export function quietDays( | |
| 18 | deal: Pick<DealView, "closed" | "fell_through" | "touched" | "deadlines">, | |
| 19 | on: string, | |
| 20 | ): number | null { | |
| 21 | if (deal.closed || deal.fell_through) return null; | |
| 22 | const days = daysBetween(deal.touched, on); | |
| 23 | if (days < QUIET_DAYS) return null; | |
| 24 | if (deal.deadlines.some((d) => isOpen(d) && daysBetween(on, d.due) >= 0 && daysBetween(on, d.due) <= SOON_DAYS)) return null; | |
| 25 | return days; | |
| 26 | } | |
| 27 | ||
| 28 | /** "Quiet 16 days": a word, so colour is never the only signal. */ | |
| 29 | export const quietLabel = (days: number): string => `Quiet ${days} days`; | |
| 30 | ||
| 31 | /** "Last touched 16 days ago", "today" or "yesterday", for the deal page. */ | |
| 32 | export function touchedLine(touched: string, on: string): string { | |
| 33 | const days = daysBetween(touched, on); | |
| 34 | return days <= 0 ? "Last touched today" : days === 1 ? "Last touched yesterday" : `Last touched ${days} days ago`; | |
| 35 | } |
src/sample.ts +11 −5
| @@ -37,6 +37,8 @@ interface Sample { | ||
| 37 | 37 | /** Closed a year ago to the day, so its home anniversary note waits today; the buyer's email. */ |
| 38 | 38 | readonly anniversary?: string; |
| 39 | 39 | readonly lengths?: Partial<typeof BRIEF_LENGTHS>; |
| 40 | /** Days since anything touched the deal: it was opened then and its waived dates stamped then, so it wears the quiet badge on every boot. */ | |
| 41 | readonly quiet?: number; | |
| 40 | 42 | /** When and where it closes, for the sample that closes this week. */ |
| 41 | 43 | readonly closingAt?: readonly [string, string]; |
| 42 | 44 | /** Role, name, email or phone, and a phone besides when the first is an email. */ |
| @@ -138,12 +140,14 @@ const SAMPLES: readonly Sample[] = [ | ||
| 138 | 140 | address: "2210 Juniper Court", |
| 139 | 141 | buyer: "Dev & Anika Rao", |
| 140 | 142 | seller: "Carla Mendes", |
| 141 | contract: -3, | |
| 142 | closing: 40, | |
| 143 | contract: -17, | |
| 144 | closing: 60, | |
| 143 | 145 | price: 389_900, |
| 144 | 146 | rate: 250, |
| 145 | 147 | earnest: 8_000, |
| 146 | lengths: { earnest: 5 }, | |
| 148 | // Nothing is due for a week and nobody has touched it for sixteen days: the quiet deal. | |
| 149 | quiet: 16, | |
| 150 | lengths: { earnest: 29, inspection: 33, title: 37, appraisal: 41, financing: 47 }, | |
| 147 | 151 | parties: [ |
| 148 | 152 | ["lender", "Owen Brandt, First Keel Bank", "[email protected]"], |
| 149 | 153 | ["other_agent", "Jordan Ellis, Crestline Homes", "(555) 014-2290"], |
| @@ -306,7 +310,7 @@ export interface SampleState { | ||
| 306 | 310 | export function isUntouched(state: SampleState, anchor: string): boolean { |
| 307 | 311 | const s = SAMPLES.find((x) => x.address === state.sample); |
| 308 | 312 | if (s === undefined) return false; |
| 309 | const marker = `${addDays(anchor, -1)}${DONE_TIME}`; | |
| 313 | const marker = `${addDays(anchor, -(s.quiet ?? 1))}${DONE_TIME}`; | |
| 310 | 314 | const stamped = (d: string | null, i: number) => d === null || (d.endsWith(DONE_TIME) && d.slice(0, 10) < state.dues[i]!) || d === marker; |
| 311 | 315 | return ( |
| 312 | 316 | state.parties === s.parties.length && |
| @@ -392,6 +396,7 @@ export function refresh(db: Database, on = today(zoneOf(db))): void { | ||
| 392 | 396 | for (const m of shifted.moves) db.query("UPDATE deadline_move SET from_due = ?1, to_due = ?2 WHERE id = ?3").run(rolled(m.fromDue), rolled(m.toDue), m.id); |
| 393 | 397 | // The sample's sent reminder follows its date, so it stays "yesterday's". |
| 394 | 398 | const days = `+${daysBetween(anchor, on)} days`; |
| 399 | db.query("UPDATE deal SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', created_at, ?1) WHERE id = ?2").run(days, deal.id); | |
| 395 | 400 | db.query("UPDATE deadline_move SET at = strftime('%Y-%m-%dT%H:%M:%fZ', at, ?1) WHERE deadline_id IN (SELECT id FROM deadline WHERE deal_id = ?2)").run(days, deal.id); |
| 396 | 401 | db.query("UPDATE reminder SET due = date(due, ?1), sent_at = strftime('%Y-%m-%dT%H:%M:%fZ', sent_at, ?1) WHERE deal_id = ?2 AND sample = 1").run(days, deal.id); |
| 397 | 402 | db.query("UPDATE reminder SET due = (SELECT due FROM deadline WHERE id = reminder.deadline_id) WHERE deal_id = ?1 AND sample = 1 AND moment <> 'checkin'").run(deal.id); |
| @@ -425,6 +430,7 @@ function writeSamples(db: Database, on: string): void { | ||
| 425 | 430 | commissionRate: s.rate, |
| 426 | 431 | side: s.side ?? "buyer", |
| 427 | 432 | }); |
| 433 | if (s.quiet !== undefined) db.query("UPDATE deal SET created_at = ?1 WHERE id = ?2").run(`${addDays(on, -s.quiet)}T09:00:00.000Z`, deal.id); | |
| 428 | 434 | if (s.closingAt !== undefined) |
| 429 | 435 | db.query("UPDATE deal SET closing_time = ?1, closing_place = ?2 WHERE id = ?3").run(s.closingAt[0], s.closingAt[1], deal.id); |
| 430 | 436 | for (const kind of APPOINTMENT_KINDS) { |
| @@ -453,7 +459,7 @@ function writeSamples(db: Database, on: string): void { | ||
| 453 | 459 | else db.query("UPDATE deadline SET done_at = min(date(due, '-1 day'), ?1) || ?2 WHERE deal_id = ?3 AND kind = ?4").run(finished, DONE_TIME, deal.id, kind); |
| 454 | 460 | } |
| 455 | 461 | for (const kind of s.waived ?? []) |
| 456 | db.query("UPDATE deadline SET waived_at = ?1, waived_by = ?2 WHERE deal_id = ?3 AND kind = ?4").run(`${addDays(on, -1)}${DONE_TIME}`, SAMPLE.name, deal.id, kind); | |
| 462 | db.query("UPDATE deadline SET waived_at = ?1, waived_by = ?2 WHERE deal_id = ?3 AND kind = ?4").run(`${addDays(on, -(s.quiet ?? 1))}${DONE_TIME}`, SAMPLE.name, deal.id, kind); | |
| 457 | 463 | for (const [kind, note] of s.notes ?? []) |
| 458 | 464 | db.query("UPDATE deadline SET note = ?1 WHERE deal_id = ?2 AND kind = ?3").run(note, deal.id, kind); |
| 459 | 465 | for (const [kind, name, isIn] of s.papers ?? []) { |
src/tour.ts +1 −1
| @@ -28,7 +28,7 @@ export const STEPS: readonly Step[] = [ | ||
| 28 | 28 | }, |
| 29 | 29 | { |
| 30 | 30 | title: "Read the clock", |
| 31 | text: "One lane per open deal. Time runs left to right and today is the dark line down the middle. Every date in a contract is a marker: red is overdue (the inspection on 77 Harbor View Dr), amber is due within three days, teal is coming up, grey is done (the earlier dates on 1418 Alder Lane, which closes in a few days). Under the heading, one line adds up your book, like \"4 open · $1,693,400 under contract · 2 close in November · 3 closed this year, $1,375,000 · $45,328 commission pending · $37,395 earned this year\" (the November count follows the month you open it in). Below the clock, **This week** lists every open date that is overdue or due in the next seven days, with who owes it.", | |
| 31 | text: "One lane per open deal. Time runs left to right and today is the dark line down the middle. Every date in a contract is a marker: red is overdue (the inspection on 77 Harbor View Dr), amber is due within three days, teal is coming up, grey is done (the earlier dates on 1418 Alder Lane, which closes in a few days). Under the heading, one line adds up your book, like \"4 open · $1,693,400 under contract · 2 close in November · 3 closed this year, $1,375,000 · $45,328 commission pending · $37,395 earned this year\" (the November count follows the month you open it in). A lane that says **Quiet 16 days** beside its address (2210 Juniper Court) is an open deal nothing has touched for two weeks and with no date due in the next three; the deal page says when it was last touched. Below the clock, **This week** lists every open date that is overdue or due in the next seven days, with who owes it.", | |
| 32 | 32 | go: { label: "the clock", page: "/" }, |
| 33 | 33 | }, |
| 34 | 34 | { |
static/app.css +12
| @@ -1043,6 +1043,18 @@ a.marker { | ||
| 1043 | 1043 | max-width: 24rem; |
| 1044 | 1044 | } |
| 1045 | 1045 | |
| 1046 | /* An open deal nothing has touched in two weeks: a word in a pill, never colour alone. */ | |
| 1047 | .quiet-badge { | |
| 1048 | display: inline-block; | |
| 1049 | padding: 0 var(); | |
| 1050 | border: 1px solid var(); | |
| 1051 | border-radius: var(); | |
| 1052 | color: var(); | |
| 1053 | font-size: var(); | |
| 1054 | font-weight: 600; | |
| 1055 | white-space: nowrap; | |
| 1056 | } | |
| 1057 | ||
| 1046 | 1058 | /* The header's Review link: the count has a slot of its own, so it appearing moves nothing. */ |
| 1047 | 1059 | .review-count { |
| 1048 | 1060 | display: inline-block; |