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

opened this

Check · Review · Land · Live — Its check is red. Next: builder, its check is red.

Ready to land

mason/deal-clock:mason/318-the-clock-says-what-changed-since-the-ag → main 3 commits fork 2 threads · 2 unresolved

  • Ready to merge — no conflicts with main. 3 commits will land on main.

    Check faileddeploy aa4c730

    Website spec: 3 findings, 3 the edge owns (report only)
    • / no <link rel="canonical"> — canonical
    • / missing og:image — open-graph
    • / http://127.0.0.1:37879/tokens.css?v=2d14bc8d: a 2410-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:37879/static/app.css?v=8047f8be: a 23492-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
    You need write access to merge.

Review

Sign in to review this change.
11 files changed +252 −12
README.md +5
@@ -18,6 +18,11 @@ weekend or a federal holiday. The samples stay current: every time the
1818 app starts, the ones nobody has touched move forward so the screen always
1919 looks like today.
2020
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
2126 Signed in, the same steps are in the app under **Tour** in the header, each
2227 with a link to the page it names. In the terminal press `G` for the same steps.
2328
docs/TECHNICAL.md +1
@@ -278,6 +278,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
278278 | `src/env.ts` | what the forge told this process |
279279 | `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 |
280280 | `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) |
281282 | `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` |
282283 | `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 |
283284 | `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[] = [
590590 `ALTER TABLE deal ADD COLUMN note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 500)`,
591591 // How new drafts sound: warm (the original wording) or brief (src/reminders.ts).
592592 `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 )`,
593599 ];
594600
595601 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", () => {
1313 });
1414
1515 test("a database made before the Profile line gets the column and reads the agent", () => {
16 const before = migrations.filter((m) => !/profile ADD COLUMN line|commission_rate|profile ADD COLUMN zone|ADD COLUMN cash|'anniversary'|deadline ADD COLUMN (time|place)|party ADD COLUMN reach|reminder_hold ADD COLUMN back_on|TABLE IF NOT EXISTS paper|deal ADD COLUMN note|deadline_move ADD COLUMN undo|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));
1717 const db = new Database(":memory:");
1818 db.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))");
1919 before.forEach((sql, i) => {
src/pages/clock.ts +6 −1
@@ -166,9 +166,11 @@ export function clock(input: {
166166 q?: string | undefined;
167167 side?: SideFilter | undefined;
168168 closing?: ClosingFilter | undefined;
169 /** "Since yesterday: 1 date moved"; empty when nothing changed or on a first visit. */
170 since?: string | undefined;
169171 }): string {
170172 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 ?? ""]) +
172174 input.deals.map(print).join(),
173175 );
174176 return pages(`${input.deals.length}:${key}`, () => render(input));
@@ -188,6 +190,8 @@ function render(input: {
188190 q?: string | undefined;
189191 side?: SideFilter | undefined;
190192 closing?: ClosingFilter | undefined;
193 /** "Since yesterday: 1 date moved"; empty when nothing changed or on a first visit. */
194 since?: string | undefined;
191195 }): string {
192196 const { who, on, notice } = input;
193197 const agent = input.agent ?? defaults;
@@ -252,6 +256,7 @@ function render(input: {
252256 <div>
253257 <h1>The clock</h1>
254258 <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>` : ""}
255260 ${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>`}
256261 </div>
257262 ${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";
108108 import { CLOSING_REFUSAL, type ClosingFilter, closingFilter, closingIn } from "./closing-window.ts";
109109 import { SIDES, SIDE_REFUSAL, onSide, sideFilter } from "./side.ts";
110110 import { clock } from "./pages/clock.ts";
111 import { sinceFor } from "./since.ts";
111112 import { dealSheets } from "./pages/sheet.ts";
112113 import { dealPage, newDealPage } from "./pages/deal.ts";
113114 import { missingPage } from "./pages/missing.ts";
@@ -822,7 +823,11 @@ export const routes: readonly Route[] = [
822823 const closing = closingFilter(c.url.searchParams.get("closing"));
823824 if (closing === undefined)
824825 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);
outdated

Hoist agent.zone ?? DEFAULT_ZONE into a constant. Then give the terminal clock the same line through visit and changesSince.

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);
826831 },
827832 },
828833 {
src/sample.test.ts +3 −2
@@ -10,7 +10,7 @@ import { dollars } from "./money.ts";
1010 import { clock } from "./pages/clock.ts";
1111 import { people } from "./people.ts";
1212 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";
1414 import { createDeal, listDeals, markDone } from "./deals.ts";
1515 import { drafts, todaysDrafts, unsent } from "./reminders.ts";
1616 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
264264 const day = today(DEFAULT_ZONE);
265265 const first = todaysDrafts(listed.value, day, agent)[0]!;
266266 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);
268269 });
269270
270271 // 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
247247 });
248248
249249 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);
250254 const t = terminal(db, alice, context);
251255 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");
252258 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();
254261 press(t, "n", "Buyer confirmed by phone", "\r");
255262 expect(lines(t).join("\n")).toContain("Not sent to");
256263 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);
258265 expect(under(t)).not.toBe(row);
259266 // An empty reason is refused with the action's words.
260267 press(t, "j", "n", "\r");
261268 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");
264272 expect(under(t)).toContain("not sent");
273 expect(under(t)).toContain("Decline check");
265274 t.press("u");
266275 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);
268277 const guest = terminal(db, { role: "anon" }, context);
269278 guest.press("r");
270279 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);
272281 });
273282
274283 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
12681277 expect(lines(guest).join("\n")).not.toContain("Private note");
12691278 expect(deal().note).toBe("");
12701279 });
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 @@
2424
2525 import type { Database } from "bun:sqlite";
2626 import { bookLine, tally } from "./book.ts";
27 import { sinceFor } from "./since.ts";
2728 import { appointmentLine, isAppointment } from "./appointment.ts";
2829 import { closingLine } from "./closing.ts";
2930 import { shiftPlan } from "./shift.ts";
@@ -145,6 +146,8 @@ export function terminal(
145146 const on = context.on ?? ((): string => today(zoneOf(db)));
146147 /** The saved profile unless a caller fixed one: read when drawn, so a save elsewhere shows at once. */
147148 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;
148151 let view: View = "dates";
149152 let cursor = 0;
150153 let dealId: number | undefined;
@@ -883,7 +886,11 @@ export function terminal(
883886 const rows = dateRows();
884887 const listed = listDeals(db, me);
885888 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 }
887894 if (listed.ok && rows.length === 0) lines.push(dim("Nothing open. Every date is done."));
888895 rows.forEach((row, i) => lines.push(dateLine(row, i, true)));
889896 const closed = all.filter((d) => d.closed && !d.fell_through);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs