Public: anyone can read this; the crew can change it.
The clock says what changed since the agent last looked: dates moved, marked done or newly overdue #320
Check · Review · Land · Live — Its check is red.
mason/deal-clock:mason/318-the-clock-says-what-changed-since-the-ag → main 3 commits fork 2 threads · 2 unresolved
11 files
changed +252
−12
README.md +5
| @@ -18,6 +18,11 @@ weekend or a federal holiday. The samples stay current: every time the | ||
| 18 | 18 | app starts, the ones nobody has touched move forward so the screen always |
| 19 | 19 | looks like today. |
| 20 | 20 | |
| 21 | Come back to the clock later and one quiet line above it says what moved since | |
| 22 | your last visit, like "Since yesterday: 1 date moved, 2 marked done, 1 newly | |
| 23 | overdue". It is absent on your first visit and when nothing changed. The | |
| 24 | terminal's date list says the same line, once when you open it. | |
| 25 | ||
| 21 | 26 | Signed in, the same steps are in the app under **Tour** in the header, each |
| 22 | 27 | with a link to the page it names. In the terminal press `G` for the same steps. |
| 23 | 28 |
docs/TECHNICAL.md +1
| @@ -278,6 +278,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 278 | 278 | | `src/env.ts` | what the forge told this process | |
| 279 | 279 | | `src/coming.ts` | the reminders a deal has yet to draft: a deal, a day and the agent in, up to 10 listed and a count of the rest out, read from `drafts` so it never disagrees; called by the deal page and the terminal | |
| 280 | 280 | | `src/find.ts` | finding a deal: `query` validates what was typed (100 characters, no control characters), `find` filters deals by address and party details; called by the clock, routes, MCP and the terminal | |
| 281 | | `src/since.ts` | the clock's "Since yesterday: 1 date moved, 2 marked done, 1 newly overdue" line: `visit` keeps each person's last look in the `visit` table (a view within 30 minutes of the last is the same look), `changesSince` counts `date.move` and `date.done` audit rows on their deals plus open dates that fell due since; `sinceFor` is the one call, made by `GET /` and by the terminal's dates view (once per session) | | |
| 281 | 282 | | `src/closing-window.ts` | narrowing the clock by closing date: `closingFilter` validates `?closing=` (week, month, later, any), `closingIn` keeps the deals closing in that window from today's date; called by `GET /` and `GET /week` | |
| 282 | 283 | | `src/guidance.ts` | the one plain line telling a client what to do for an open date: kind and side in, a string out; called by the client timeline and its page | |
| 283 | 284 | | `src/memo.ts` | work done once per deal content: `print` keys a deal by its rows, `memoizer(limit)` is a bounded cache; called by `src/http.ts`, `src/reminders.ts` and the clock and sheet pages | |
src/db.ts +6
| @@ -590,6 +590,12 @@ 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 | // When each person last looked at the clock (`at`), and the look before it (`since`) that "what changed" is counted from. | |
| 594 | `CREATE TABLE IF NOT EXISTS visit ( | |
| 595 | who_id TEXT PRIMARY KEY, | |
| 596 | at TEXT NOT NULL, | |
| 597 | since TEXT | |
| 598 | )`, | |
| 593 | 599 | ]; |
| 594 | 600 | |
| 595 | 601 | export function openDb(dir = dataDir): Database { |
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|TABLE IF NOT EXISTS visit/.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 +6 −1
| @@ -166,9 +166,11 @@ export function clock(input: { | ||
| 166 | 166 | q?: string | undefined; |
| 167 | 167 | side?: SideFilter | undefined; |
| 168 | 168 | closing?: ClosingFilter | undefined; |
| 169 | /** "Since yesterday: 1 date moved"; empty when nothing changed or on a first visit. */ | |
| 170 | since?: string | undefined; | |
| 169 | 171 | }): string { |
| 170 | 172 | const key = Bun.hash( |
| 171 | JSON.stringify([input.who, input.on, input.notice ?? null, input.open ?? null, input.q ?? "", input.side ?? "", input.closing ?? "", input.agent, input.waiting, input.tour]) + | |
| 173 | JSON.stringify([input.who, input.on, input.notice ?? null, input.open ?? null, input.q ?? "", input.side ?? "", input.closing ?? "", input.agent, input.waiting, input.tour, input.since ?? ""]) + | |
| 172 | 174 | input.deals.map(print).join(), |
| 173 | 175 | ); |
| 174 | 176 | return pages(`${input.deals.length}:${key}`, () => render(input)); |
| @@ -188,6 +190,8 @@ function render(input: { | ||
| 188 | 190 | q?: string | undefined; |
| 189 | 191 | side?: SideFilter | undefined; |
| 190 | 192 | closing?: ClosingFilter | undefined; |
| 193 | /** "Since yesterday: 1 date moved"; empty when nothing changed or on a first visit. */ | |
| 194 | since?: string | undefined; | |
| 191 | 195 | }): string { |
| 192 | 196 | const { who, on, notice } = input; |
| 193 | 197 | const agent = input.agent ?? defaults; |
| @@ -252,6 +256,7 @@ function render(input: { | ||
| 252 | 256 | <div> |
| 253 | 257 | <h1>The clock</h1> |
| 254 | 258 | <p class="summary"><span>${escape(spoken(on))}</span> · <span>${deals.length} ${deals.length === 1 ? "deal" : "deals"}</span>${overdue > 0 ? ` · <b class="u-overdue">${overdue} overdue</b>` : ""}${soon > 0 ? ` · <b class="u-soon">${soon} due within 3 days</b>` : ""}</p> |
| 259 | ${input.since ? `<p class="summary muted" id="since">${escape(input.since)}</p>` : ""} | |
| 255 | 260 | ${input.deals.length === 0 ? "" : `<p class="summary muted">${escape(bookLine(tally(input.deals, on), on))}</p><p class="summary muted">${escape(paceLine(paceOf(input.deals.map(pacing), on)))}</p>`} |
| 256 | 261 | </div> |
| 257 | 262 | ${who.role === "anon" ? "" : `<span class="actions"><a class="button button-quiet" href="/deals/ics">Add all to calendar</a> <a class="button button-quiet" href="/deals/csv" download>Download spreadsheet</a> <a class="button" href="/deals/new">New deal</a></span>`} |
src/routes.ts +6 −1
| @@ -108,6 +108,7 @@ import { FIND_REFUSAL, find, query } from "./find.ts"; | ||
| 108 | 108 | import { CLOSING_REFUSAL, type ClosingFilter, closingFilter, closingIn } from "./closing-window.ts"; |
| 109 | 109 | import { SIDES, SIDE_REFUSAL, onSide, sideFilter } from "./side.ts"; |
| 110 | 110 | import { clock } from "./pages/clock.ts"; |
| 111 | import { sinceFor } from "./since.ts"; | |
| 111 | 112 | import { dealSheets } from "./pages/sheet.ts"; |
| 112 | 113 | import { dealPage, newDealPage } from "./pages/deal.ts"; |
| 113 | 114 | import { missingPage } from "./pages/missing.ts"; |
| @@ -822,7 +823,11 @@ export const routes: readonly Route[] = [ | ||
| 822 | 823 | const closing = closingFilter(c.url.searchParams.get("closing")); |
| 823 | 824 | if (closing === undefined) |
| 824 | 825 | return html(refusedPage({ who: c.who, agent: agentFor(c), path: "/", heading: "The clock", error: "invalid", message: CLOSING_REFUSAL }), 422, clear); |
| 825 | return html(clock({ who: c.who, waiting: waitingCount(deals.value, dayOf(agentFor(c)), agentFor(c)), tour: hasTourDeals(deals.value), agent: agentFor(c), deals: deals.value, on: dayOf(agentFor(c)), notice, open, q, side, closing }), 200, clear); | |
| 826 | const agent = agentFor(c); | |
| 827 | const on = dayOf(agent); | |
| 828 | const zone = agent.zone ?? DEFAULT_ZONE; | |
| 829 | const since = sinceFor(c.db, c.who, deals.value, zone, on); | |
| 830 | return html(clock({ who: c.who, waiting: waitingCount(deals.value, on, agent), tour: hasTourDeals(deals.value), agent, deals: deals.value, on, notice, open, q, side, closing, since }), 200, clear); | |
| 826 | 831 | }, |
| 827 | 832 | }, |
| 828 | 833 | { |
src/sample.test.ts +3 −2
| @@ -10,7 +10,7 @@ import { dollars } from "./money.ts"; | ||
| 10 | 10 | import { clock } from "./pages/clock.ts"; |
| 11 | 11 | import { people } from "./people.ts"; |
| 12 | 12 | import { thisWeek } from "./week.ts"; |
| 13 | import { addDays, daysBetween, isBusinessDay, today, spoken, DEFAULT_ZONE } from "./dates.ts"; | |
| 13 | import { addDays, daysBetween, isBusinessDay, nextBusinessDay, today, spoken, DEFAULT_ZONE } from "./dates.ts"; | |
| 14 | 14 | import { createDeal, listDeals, markDone } from "./deals.ts"; |
| 15 | 15 | import { drafts, todaysDrafts, unsent } from "./reminders.ts"; |
| 16 | 16 | import { serve } from "./server.ts"; |
| @@ -264,7 +264,8 @@ test("on a fresh boot the first reminder for today is for a date already late or | ||
| 264 | 264 | const day = today(DEFAULT_ZONE); |
| 265 | 265 | const first = todaysDrafts(listed.value, day, agent)[0]!; |
| 266 | 266 | expect(first).toBeDefined(); |
| 267 | expect(first.moment === "late" || first.due <= day).toBe(true); | |
| 267 | // A weekend or holiday boot rolls the day's own date to the next business day, so that is the latest it may fall. | |
| 268 | expect(first.moment === "late" || first.due <= (isBusinessDay(day) ? day : nextBusinessDay(day))).toBe(true); | |
| 268 | 269 | }); |
| 269 | 270 | |
| 270 | 271 | // The samples are written relative to the day of the boot; the tour in the README must read the same across a month or year end. |
src/since.test.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** "Since yesterday: …": the clock says what moved since the agent last looked. */ | |
| 2 | import { afterAll, beforeEach, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createDeal, markDone, moveDeadline } from "./deals.ts"; | |
| 7 | import { openDb } from "./db.ts"; | |
| 8 | import { forgetRationing } from "./security.ts"; | |
| 9 | import { serve } from "./server.ts"; | |
| 10 | import { changesSince, SAME_LOOK_MS, sinceLine, visit } from "./since.ts"; | |
| 11 | import { sign, type Who } from "./who.ts"; | |
| 12 | ||
| 13 | const KEY = "a-key-only-the-forge-holds"; | |
| 14 | const dir = mkdtempSync(join(tmpdir(), "app-since-")); | |
| 15 | const db = openDb(dir); | |
| 16 | const server = serve({ port: 0, dataDir: dir, identityKey: KEY }); | |
| 17 | const base = `http://${server.hostname}:${server.port}`; | |
| 18 | afterAll(() => server.stop(true)); | |
| 19 | beforeEach(forgetRationing); | |
| 20 | ||
| 21 | const who = (id: string): Who => ({ role: "user", id, name: id }); | |
| 22 | const as = (id: string): Record<string, string> => { | |
| 23 | const identity = `${id}|${id}|user|${Date.now() + 600_000}`; | |
| 24 | return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; | |
| 25 | }; | |
| 26 | const DAY = 86_400_000; | |
| 27 | ||
| 28 | test("the line names each count, and is empty when nothing changed", () => { | |
| 29 | expect(sinceLine({ moved: 1, done: 2, overdue: 1 }, "Since yesterday")).toBe("Since yesterday: 1 date moved, 2 marked done, 1 newly overdue"); | |
| 30 | expect(sinceLine({ moved: 3, done: 0, overdue: 0 }, "Since yesterday")).toBe("Since yesterday: 3 dates moved"); | |
| 31 | expect(sinceLine({ moved: 0, done: 0, overdue: 0 }, "Since yesterday")).toBe(""); | |
| 32 | }); | |
| 33 | ||
| 34 | test("the first visit has no last visit; a later one is counted from the one before, and a reload keeps it", () => { | |
| 35 | const t = new Date("2026-11-01T10:00:00Z"); | |
| 36 | expect(visit(db, who("v1"), t)).toBeNull(); | |
| 37 | expect(visit(db, who("v1"), new Date(t.getTime() + DAY))).toBe(t.toISOString()); | |
| 38 | const later = new Date(t.getTime() + DAY + SAME_LOOK_MS / 2); | |
| 39 | expect(visit(db, who("v1"), later)).toBe(t.toISOString()); | |
| 40 | expect(visit(db, { role: "anon" } as Who, t)).toBeNull(); | |
| 41 | }); | |
| 42 | ||
| 43 | test("moves and done marks since the last look are counted, on this person's deals only", () => { | |
| 44 | const alice = who("alice"); | |
| 45 | const made = createDeal(db, alice, { openAnyway: true, address: "1 Since St", buyer: "Ann", seller: "Sam", contractDate: "2026-10-01", closingDate: "2026-12-20" }); | |
| 46 | if (!made.ok) throw new Error(made.message); | |
| 47 | const [first, second] = made.value.deadlines.filter((d) => d.kind !== "closing"); | |
| 48 | const before = new Date(Date.now() - 1000).toISOString(); | |
| 49 | expect(moveDeadline(db, alice, { id: first!.id, due: "2026-12-01", reason: "Seller asked" }).ok).toBe(true); | |
| 50 | expect(markDone(db, alice, { id: second!.id }).ok).toBe(true); | |
| 51 | const deal = { id: made.value.id, closed: false, fell_through: false, deadlines: [] }; | |
| 52 | expect(changesSince(db, [deal], before, "UTC", "2026-11-11")).toEqual({ moved: 1, done: 1, overdue: 0 }); | |
| 53 | expect(changesSince(db, [deal], new Date(Date.now() + 1000).toISOString(), "UTC", "2026-11-11")).toEqual({ moved: 0, done: 0, overdue: 0 }); | |
| 54 | expect(changesSince(db, [], before, "UTC", "2026-11-11")).toEqual({ moved: 0, done: 0, overdue: 0 }); | |
| 55 | }); | |
| 56 | ||
| 57 | test("newly overdue counts open dates that fell due after the last look", () => { | |
| 58 | const open = (due: string) => ({ due, done_at: null, waived_at: null }); | |
| 59 | const deal = { id: 1, closed: false, fell_through: false, deadlines: [open("2026-11-09"), open("2026-11-05"), open("2026-11-12"), { due: "2026-11-10", done_at: "x", waived_at: null }] }; | |
| 60 | expect(changesSince(db, [deal], "2026-11-08T12:00:00Z", "UTC", "2026-11-11").overdue).toBe(1); | |
| 61 | }); | |
| 62 | ||
| 63 | test("the clock shows the line, escaped, only after an earlier visit that saw a change", async () => { | |
| 64 | const bob = who("bob"); | |
| 65 | const hdr = as("bob"); | |
| 66 | const first = await (await fetch(base + "/", { headers: hdr })).text(); | |
| 67 | expect(first).not.toContain('id="since"'); | |
| 68 | const made = createDeal(db, bob, { openAnyway: true, address: "2 Since St", buyer: "Ann", seller: "Sam", contractDate: "2026-10-01", closingDate: "2026-12-20" }); | |
| 69 | if (!made.ok) throw new Error(made.message); | |
| 70 | const inspection = made.value.deadlines.find((d) => d.kind === "inspection")!; | |
| 71 | expect(moveDeadline(db, bob, { id: inspection.id, due: "2026-12-02", reason: "Seller asked" }).ok).toBe(true); | |
| 72 | // Quiet: the earlier look was a moment ago, so it is the same look. | |
| 73 | expect(await (await fetch(base + "/", { headers: hdr })).text()).not.toContain('id="since"'); | |
| 74 | db.query("UPDATE visit SET at = ?1, since = ?1 WHERE who_id = 'bob'").run(new Date(Date.now() - 3600_000).toISOString()); | |
| 75 | const html = await (await fetch(base + "/", { headers: hdr })).text(); | |
| 76 | expect(html).toContain('id="since"'); | |
| 77 | expect(html).toMatch(/Since (earlier today|yesterday): \d+ dates? moved/); | |
| 78 | }); |
src/since.ts added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | /** | |
| 2 | * What changed since the agent last looked at the clock: one quiet line. | |
| 3 | * The last visit is kept per person in `visit`; the counts come from the | |
| 4 | * audit trail (`date.move`, `date.done`) and from the dates themselves | |
| 5 | * (newly overdue). Plain data in, a sentence out. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { daysBetween, dayOfStamp, spoken } from "./dates.ts"; | |
| 10 | import { isOpen } from "./deadlines.ts"; | |
| 11 | import type { Who } from "./who.ts"; | |
| 12 | ||
| 13 | /** A view closer than this to the last one is the same look (a reload), and keeps the line. */ | |
| 14 | export const SAME_LOOK_MS = 30 * 60_000; | |
| 15 | ||
| 16 | export interface Since { | |
| 17 | readonly moved: number; | |
| 18 | readonly done: number; | |
| 19 | readonly overdue: number; | |
| 20 | } | |
| 21 | ||
| 22 | interface Visit { | |
| 23 | readonly at: string; | |
| 24 | readonly since: string | null; | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * Record a view of the clock by `who` at `now`, and answer the moment the | |
| 29 | * agent last looked before this (null on a first visit). Anonymous people | |
| 30 | * have no visit. | |
| 31 | */ | |
| 32 | export function visit(db: Database, who: Who, now: Date): string | null { | |
| 33 | if (who.role === "anon") return null; | |
| 34 | const at = now.toISOString(); | |
| 35 | const row = db.query<Visit, [string]>("SELECT at, since FROM visit WHERE who_id = ?1").get(who.id); | |
| 36 | if (row === null) { | |
| 37 | db.query("INSERT INTO visit (who_id, at, since) VALUES (?1, ?2, NULL)").run(who.id, at); | |
| 38 | return null; | |
| 39 | } | |
| 40 | const fresh = now.getTime() - Date.parse(row.at) >= SAME_LOOK_MS; | |
| 41 | const since = fresh ? row.at : row.since; | |
| 42 | db.query("UPDATE visit SET at = ?2, since = ?3 WHERE who_id = ?1").run(who.id, at, since); | |
| 43 | return since; | |
| 44 | } | |
| 45 | ||
| 46 | interface Dated { | |
| 47 | readonly id: number; | |
| 48 | readonly closed: boolean; | |
| 49 | readonly fell_through: boolean; | |
| 50 | readonly deadlines: readonly { readonly due: string; readonly done_at: string | null; readonly waived_at: string | null }[]; | |
| 51 | } | |
| 52 | ||
| 53 | /** What moved, was marked done or became overdue on these deals since `since` (an ISO moment). */ | |
| 54 | export function changesSince(db: Database, deals: readonly Dated[], since: string, zone: string, on: string): Since { | |
| 55 | const ids = deals.map((d) => String(d.id)); | |
| 56 | let moved = 0; | |
| 57 | let done = 0; | |
| 58 | if (ids.length > 0) { | |
| 59 | const rows = db | |
| 60 | .query<{ what: string; n: number }, string[]>( | |
| 61 | `SELECT what, count(*) AS n FROM audit | |
| 62 | WHERE at > ?1 AND outcome = 'ok' AND resource = 'deal' AND what IN ('date.move', 'date.done') | |
| 63 | AND target IN (${ids.map((_, i) => `?${i + 2}`).join(",")}) | |
| 64 | GROUP BY what`, | |
| 65 | ) | |
| 66 | .all(since, ...ids); | |
| 67 | for (const r of rows) if (r.what === "date.move") moved = r.n; | |
| 68 | for (const r of rows) if (r.what === "date.done") done = r.n; | |
| 69 | } | |
| 70 | // Overdue is due before today; it was not yet on the day of the last look when due on or after that day. | |
| 71 | const last = dayOfStamp(zone, since); | |
| 72 | const overdue = deals | |
| 73 | .filter((d) => !d.closed && !d.fell_through) | |
| 74 | .flatMap((d) => d.deadlines) | |
| 75 | .filter((d) => isOpen(d) && d.due >= last && d.due < on).length; | |
| 76 | return { moved, done, overdue }; | |
| 77 | } | |
| 78 | ||
| 79 | /** "Since yesterday", "Since earlier today", "Since Mon, Nov 9". */ | |
| 80 | export function sinceWhen(zone: string, since: string, on: string): string { | |
| 81 | const day = dayOfStamp(zone, since); | |
| 82 | const ago = daysBetween(day, on); | |
| 83 | return ago <= 0 ? "Since earlier today" : ago === 1 ? "Since yesterday" : `Since ${spoken(day)}`; | |
| 84 | } | |
| 85 | ||
| 86 | /** "Since yesterday: 1 date moved, 2 marked done, 1 newly overdue"; empty when nothing changed. */ | |
| 87 | export function sinceLine(changes: Since, when: string): string { | |
| 88 | const parts = [ | |
| 89 | changes.moved > 0 ? `${changes.moved} ${changes.moved === 1 ? "date" : "dates"} moved` : "", | |
| 90 | changes.done > 0 ? `${changes.done} marked done` : "", | |
| 91 | changes.overdue > 0 ? `${changes.overdue} newly overdue` : "", | |
| 92 | ].filter((p) => p !== ""); | |
| 93 | return parts.length === 0 ? "" : `${when}: ${parts.join(", ")}`; | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * The line the clock says to `who` at `now`, and the visit it records: one call for every door that draws | |
| 98 | * the clock. Empty on a first visit, for a guest and when nothing changed. | |
| 99 | */ | |
| 100 | export function sinceFor(db: Database, who: Who, deals: readonly Dated[], zone: string, on: string, now = new Date()): string { | |
| 101 | const last = visit(db, who, now); | |
| 102 | return last === null ? "" : sinceLine(changesSince(db, deals, last, zone, on), sinceWhen(zone, last, on)); | |
| 103 | } |
src/terminal.test.ts +35 −6
| @@ -247,28 +247,37 @@ test("e edits the party under the cursor; X removes a party or a custom date aft | ||
| 247 | 247 | }); |
| 248 | 248 | |
| 249 | 249 | test("n declines a reminder with a reason, u undoes it; anon is not asked to type", () => { |
| 250 | // Its own deal and date, so the reminder is due today whatever day the suite runs on (the samples roll off weekends and holidays). | |
| 251 | const own = createDeal(db, alice, { openAnyway: true, address: "7 Decline Way", buyer: "Dee Buyer", seller: "Sid Seller", contractDate: today(DEFAULT_ZONE), closingDate: addDays(today(DEFAULT_ZONE), 45) }); | |
| 252 | if (!own.ok) throw new Error(own.message); | |
| 253 | expect(addDeadline(db, alice, { dealId: own.value.id, label: "Decline check", due: addDays(today(DEFAULT_ZONE), 3), owes: "buyer" }).ok).toBe(true); | |
| 250 | 254 | const t = terminal(db, alice, context); |
| 251 | 255 | t.press("r"); |
| 256 | for (let i = 0; i < 400 && !under(t).includes("Decline check"); i++) t.press("j"); | |
| 257 | expect(under(t)).toContain("Decline check"); | |
| 252 | 258 | const row = under(t); |
| 253 | const before = db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n; | |
| 259 | const declined = () => db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n; | |
| 260 | const before = declined(); | |
| 254 | 261 | press(t, "n", "Buyer confirmed by phone", "\r"); |
| 255 | 262 | expect(lines(t).join("\n")).toContain("Not sent to"); |
| 256 | 263 | expect(db.query<{ reason: string }, []>("SELECT reason FROM reminder_declined ORDER BY id DESC").get()!.reason).toBe("Buyer confirmed by phone"); |
outdated
Conditional assertion: the undo path is not tested on non-business days. Seed a deterministic deal or move this fix to its own pull. |
||
| 257 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n).toBe(before + 1); | |
| 264 | expect(declined()).toBe(before + 1); | |
| 258 | 265 | expect(under(t)).not.toBe(row); |
| 259 | 266 | // An empty reason is refused with the action's words. |
| 260 | 267 | press(t, "j", "n", "\r"); |
| 261 | 268 | expect(lines(t).join("\n")).toContain("Say why"); |
| 262 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n).toBe(before + 1); | |
| 263 | t.press("k"); | |
| 269 | expect(declined()).toBe(before + 1); | |
| 270 | // It is due today, so it stays listed as not sent; go back to it and undo. | |
| 271 | for (let i = 0; i < 400 && !under(t).includes("not sent"); i++) t.press("k"); | |
| 264 | 272 | expect(under(t)).toContain("not sent"); |
| 273 | expect(under(t)).toContain("Decline check"); | |
| 265 | 274 | t.press("u"); |
| 266 | 275 | expect(lines(t).join("\n")).toContain("is a draft again"); |
| 267 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n).toBe(before); | |
| 276 | expect(declined()).toBe(before); | |
| 268 | 277 | const guest = terminal(db, { role: "anon" }, context); |
| 269 | 278 | guest.press("r"); |
| 270 | 279 | press(guest, "n", "x", "\r"); |
| 271 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n).toBe(before); | |
| 280 | expect(declined()).toBe(before); | |
| 272 | 281 | }); |
| 273 | 282 | |
| 274 | 283 | test("h holds a reminder until tomorrow: it leaves the list, is written, and anon cannot", () => { |
| @@ -1268,3 +1277,23 @@ test("K keeps a private note on the open deal, shows it in the deal header, clea | ||
| 1268 | 1277 | expect(lines(guest).join("\n")).not.toContain("Private note"); |
| 1269 | 1278 | expect(deal().note).toBe(""); |
| 1270 | 1279 | }); |
| 1280 | ||
| 1281 | test("the clock says what moved since the last look, drawn once per session, and a guest hears nothing", () => { | |
| 1282 | // A database of its own: the moves other tests made would be counted too. | |
| 1283 | const db = openDb(mkdtempSync(join(tmpdir(), "app-terminal-since-"))); | |
| 1284 | const carol: Who = { role: "user", id: "u-carol", name: "carol" }; | |
| 1285 | const mine = createDeal(db, carol, { openAnyway: true, address: "3 Moved Ct", buyer: "Cy Buyer", seller: "Su Seller", contractDate: today(DEFAULT_ZONE), closingDate: addDays(today(DEFAULT_ZONE), 45) }); | |
| 1286 | if (!mine.ok) throw new Error(mine.message); | |
| 1287 | const first = terminal(db, carol, context); | |
| 1288 | expect(lines(first).join("\n")).not.toContain("Since "); | |
| 1289 | const inspection = mine.value.deadlines.find((d) => d.kind === "inspection")!; | |
| 1290 | expect(moveDeadline(db, carol, { id: inspection.id, due: addDays(inspection.due, 1), reason: "Seller asked" }).ok).toBe(true); | |
| 1291 | // The look before this one was an hour ago. | |
| 1292 | db.query("UPDATE visit SET at = ?1, since = ?1 WHERE who_id = 'u-carol'").run(new Date(Date.now() - 3600_000).toISOString()); | |
| 1293 | const again = terminal(db, carol, context); | |
| 1294 | expect(lines(again).join("\n")).toMatch(/Since (earlier today|yesterday): 1 date moved/); | |
| 1295 | // A key press redraws; it is not another visit. | |
| 1296 | again.press("j"); | |
| 1297 | expect(lines(again).join("\n")).toMatch(/Since (earlier today|yesterday): 1 date moved/); | |
| 1298 | expect(lines(terminal(db, { role: "anon" }, context)).join("\n")).not.toContain("Since "); | |
| 1299 | }); |
src/terminal.ts +8 −1
| @@ -24,6 +24,7 @@ | ||
| 24 | 24 | |
| 25 | 25 | import type { Database } from "bun:sqlite"; |
| 26 | 26 | import { bookLine, tally } from "./book.ts"; |
| 27 | import { sinceFor } from "./since.ts"; | |
| 27 | 28 | import { appointmentLine, isAppointment } from "./appointment.ts"; |
| 28 | 29 | import { closingLine } from "./closing.ts"; |
| 29 | 30 | import { shiftPlan } from "./shift.ts"; |
| @@ -145,6 +146,8 @@ export function terminal( | ||
| 145 | 146 | const on = context.on ?? ((): string => today(zoneOf(db))); |
| 146 | 147 | /** The saved profile unless a caller fixed one: read when drawn, so a save elsewhere shows at once. */ |
| 147 | 148 | const agentNow = (): Agent => context.agent ?? agentOf(db); |
| 149 | /** What the clock says moved since the last look: asked once, when the dates are first drawn, so a redraw is not another visit. */ | |
| 150 | let since: string | undefined; | |
| 148 | 151 | let view: View = "dates"; |
| 149 | 152 | let cursor = 0; |
| 150 | 153 | let dealId: number | undefined; |
| @@ -883,7 +886,11 @@ export function terminal( | ||
| 883 | 886 | const rows = dateRows(); |
| 884 | 887 | const listed = listDeals(db, me); |
| 885 | 888 | if (!listed.ok) lines.push(listed.message); |
| 886 | else lines.push(dim(bookLine(tally(listed.value, day), day))); | |
| 889 | else { | |
| 890 | since ??= sinceFor(db, me, listed.value, agentNow().zone ?? DEFAULT_ZONE, day); | |
| 891 | if (since !== "") lines.push(dim(since)); | |
| 892 | lines.push(dim(bookLine(tally(listed.value, day), day))); | |
| 893 | } | |
| 887 | 894 | if (listed.ok && rows.length === 0) lines.push(dim("Nothing open. Every date is done.")); |
| 888 | 895 | rows.forEach((row, i) => lines.push(dateLine(row, i, true))); |
| 889 | 896 | const closed = all.filter((d) => d.closed && !d.fell_through); |
Hoist
agent.zone ?? DEFAULT_ZONEinto a constant. Then give the terminal clock the same line throughvisitandchangesSince.