Public: anyone can read this; the crew can change it.

The agent can narrow the clock to deals closing this week, this month or later #314

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/deal-clock:mason/309-the-agent-can-narrow-the-clock-to-deals → main 11 commits fork 1 thread · 1 unresolved

  • Merged. mason/309-the-agent-can-narrow-the-clock-to-deals was merged into main as eaca2f3.

Review

Sign in to review this change.
11 files changed +290 −48
.gitignore +14
@@ -2,3 +2,17 @@ node_modules/
22 *.db
33 *.db-wal
44 *.db-shm
5
6 # empty placeholders the sandbox creates in the clone
7 /.bash_profile
8 /.bashrc
9 /.zshrc
10 /.zprofile
11 /.profile
12 /.gitconfig
13 /.gitmodules
14 /.mcp.json
15 /.ripgreprc
16 /.vscode
17 /.idea
18 /.claude/
README.md +13 −11
@@ -127,17 +127,19 @@ still open. Mark done anyway?", and goes ahead on `yes`.
127127
128128 ## Finding a deal
129129
130 Type part of an address, or of a buyer's, seller's or other party's name,
131 phone or email, into **Find a deal** above the clock and press Find (try
132 `harbor`). The clock, This week and today's reminders narrow to the matches;
133 **Clear** brings them back. The **Side** choice beside it narrows to deals
134 where you are the buyer's agent or the listing agent. 9 Orchard Row is a
135 listing in the samples; on a listing, add "Listing agreement expires" as a
136 date and the clock and the deal page flag a closing that falls after it (9
137 Orchard Row shows the flag). It is yours alone: it is never on the client
138 timeline and no party is drafted anything about it. You see it on the clock, in
139 This week and on the deal's page, and the calendar file gives it an alarm 30
140 days ahead.
130 Type part of an address, or of a buyer's, seller's or other party's name, phone
131 or email, into **Find a deal** above the clock and press Find (try `harbor`).
132 The clock, This week and today's reminders narrow to the matches; **Clear**
133 brings them back. The **Side** choice beside it narrows to deals where you are
134 the buyer's agent or the listing agent. The **Closing** choice (Any, This week,
135 This month, Later) narrows to deals by closing date and composes with Find and
136 Side, and the Review all link carries it to today's reminders; This week is today and the six days after, This month runs to the month's
137 end, and an unknown value is refused. 9 Orchard Row is a listing in the
138 samples; on a listing, add "Listing agreement expires" as a date and the clock
139 and the deal page flag a closing that falls after it (9 Orchard Row shows the
140 flag). It is yours alone: it is never on the client timeline and no party is
141 drafted anything about it. You see it on the clock, in This week and on the
142 deal's page, and the calendar file gives it an alarm 30 days ahead.
141143
142144 ## Opening and shaping a deal
143145
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/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` |
281282 | `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 |
282283 | `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 |
283284 | `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 |
src/closing-window.test.ts added +137
@@ -0,0 +1,137 @@
1 /** Narrowing the clock by closing window: the buckets, the boundary day, q composing, a bad value refused, and the clock carrying it. */
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 { CLOSINGS, closingFilter, closingIn } from "./closing-window.ts";
7 import { addDays } from "./dates.ts";
8 import { openDb } from "./db.ts";
9 import { createDeal, listDeals } from "./deals.ts";
10 import { restore } from "./sample.ts";
11 import { find } from "./find.ts";
12 import { forgetRationing } from "./security.ts";
13 import { serve } from "./server.ts";
14 import { sign, type Who } from "./who.ts";
15
16 const on = "2026-11-11"; // a month with 19 days left
17 const at = (days: number) => ({ closing: addDays(on, days) });
18
19 test("each bucket, with the boundary days", () => {
20 const deals = [at(-2), at(0), at(6), at(7), at(19), at(20), at(90)].map((d, i) => ({ id: i, ...d }));
21 const ids = (w: Parameters<typeof closingIn>[2]) => closingIn(deals, on, w).map((d) => d.id);
22 expect(ids("")).toEqual([0, 1, 2, 3, 4, 5, 6]);
23 expect(ids("week")).toEqual([0, 1, 2]); // today + 6 is the last day of the week
24 expect(ids("month")).toEqual([0, 1, 2, 3, 4]); // 2026-11-30 is the last day of the month
25 expect(ids("later")).toEqual([5, 6]);
26 });
27
28 test("a week that runs past the month's end is still inside this month", () => {
29 const deals = [{ closing: "2026-12-03" }, { closing: "2026-12-05" }];
30 expect(closingIn(deals, "2026-11-28", "month")).toEqual([deals[0]!]);
31 expect(closingIn(deals, "2026-11-28", "later")).toEqual([deals[1]!]);
32 });
33
34 test("the value is validated against the list", () => {
35 expect(closingFilter(null)).toBe("");
36 expect(closingFilter("any")).toBe("");
37 for (const c of CLOSINGS) expect(closingFilter(c)).toBe(c);
38 for (const bad of ["Week", "year", "week;", "<b>"]) expect(closingFilter(bad)).toBeUndefined();
39 });
40
41 const KEY = "a-key-only-the-forge-holds";
42 const dir = mkdtempSync(join(tmpdir(), "app-closing-"));
43 const db = openDb(dir);
44 const alice: Who = { role: "user", id: "u1", name: "alice" };
45 const server = serve({ port: 0, dataDir: dir, identityKey: KEY });
46 const base = `http://${server.hostname}:${server.port}`;
47 afterAll(() => server.stop(true));
48 beforeEach(forgetRationing);
49 const identity = `u1|alice|user|${Date.now() + 600_000}`;
50 const user = { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
51 const get = (path: string) => fetch(base + path, { headers: user });
52
53 test("the clock and the week sheet narrow, keep the choice, and refuse a bad one", async () => {
54 const today = new Date().toISOString().slice(0, 10);
55 for (const [address, days] of [["1 Soon Way", 2], ["2 Far Way", 400]] as const) {
56 const made = createDeal(db, alice, { address, buyer: "Ann Buyer", seller: "Sam Seller", contractDate: addDays(today, -10), closingDate: addDays(today, days), openAnyway: true });
57 if (!made.ok) throw new Error(made.message);
58 }
59 const week = await (await get("/?closing=week")).text();
60 expect(week).toContain("1 Soon Way");
61 expect(week).not.toContain("2 Far Way");
62 expect(week).toContain('<option value="week" selected>');
63 expect(week).toContain("closing=week");
64 const later = await (await get("/?closing=later&q=far")).text();
65 expect(later).toContain("2 Far Way");
66 expect(later).not.toContain("1 Soon Way");
67 expect(await (await get("/?closing=later&q=soon")).text()).toContain("No deal matches");
68 const bad = await get("/?closing=year");
69 expect(bad.status).toBe(422);
70 expect(await bad.text()).toContain("Closing is any, week, month or later.");
71 expect((await get("/week?closing=year")).status).toBe(422);
72 expect(await (await get("/week?closing=later")).text()).not.toContain("1 Soon Way");
73 });
74
75 const mine = () => {
76 const listed = listDeals(db, alice);
77 if (!listed.ok) throw new Error(listed.message);
78 return listed.value;
79 };
80
81 test("the sample deals land in the buckets their closing days say", () => {
82 const today = new Date().toISOString().slice(0, 10);
83 db.query("DELETE FROM deal").run();
84 restore(db, today);
85 const open = mine().filter((d) => !d.closed && !d.fell_through);
86 const days = (w: Parameters<typeof closingIn>[2]) => closingIn(open, today, w).map((d) => d.closing).sort();
87 const at4 = addDays(today, 2);
88 const at33 = addDays(today, 33);
89 const at40 = addDays(today, 40);
90 // The samples close in 2, 16, 33 and 40 days; no month ends more than 30 days out.
91 expect(open.map((d) => d.closing).sort()).toEqual([at4, addDays(today, 16), at33, at40].sort());
92 expect(days("week")).toEqual([at4]);
93 expect(days("month")).toContain(at4);
94 expect(days("month")).not.toContain(at33);
95 expect(days("later")).toEqual(expect.arrayContaining([at33, at40]));
96 expect(days("later")).not.toContain(at4);
97 expect(days("")).toHaveLength(4);
98 });
99
100 test("today's reminders narrow by closing too, and the clock's Review all link carries it", async () => {
101 const today = new Date().toISOString().slice(0, 10);
102 restore(db, today);
103 const total = async (path: string) => Number(/ of (\d+)<\/p>/.exec(await (await get(path)).text())?.[1] ?? 0);
104 const all = await total("/reminders/today");
105 const week = await total("/reminders/today?closing=week");
106 const later = await total("/reminders/today?closing=later");
107 expect(all).toBeGreaterThan(0);
108 expect(week).toBeLessThan(all);
109 expect(later).toBeLessThan(all);
110 expect(await (await get("/reminders/today?closing=later")).text()).toContain('name="closing" value="later"');
111 const bad = await get("/reminders/today?closing=year");
112 expect(bad.status).toBe(400);
113 expect(await bad.text()).toContain("Closing is any, week, month or later.");
114 const clock = await (await get("/?closing=week")).text();
115 expect(clock).toMatch(/href="\/reminders\/today\?closing=week"/);
116 });
117
118 test("composes with find on real deals", () => {
119 const today = new Date().toISOString().slice(0, 10);
120 restore(db, today);
121 const deals = mine();
122 const harbor = find(deals, "harbor");
123 expect(harbor.length).toBeGreaterThan(0);
124 const inMonth = closingIn(harbor, today, "month").length;
125 expect(inMonth + closingIn(harbor, today, "later").length).toBe(harbor.length);
126 expect(closingIn(find(deals, "zzz-nothing"), today, "week")).toEqual([]);
127 });
128
129 test("the empty message names what narrowed", async () => {
130 const today = new Date().toISOString().slice(0, 10);
131 db.query("DELETE FROM deal").run();
132 const made = createDeal(db, alice, { address: "4 Lone Way", buyer: "Ann Buyer", seller: "Sam Seller", contractDate: addDays(today, -10), closingDate: addDays(today, 400), openAnyway: true });
133 if (!made.ok) throw new Error(made.message);
134 expect(await (await get("/?closing=week")).text()).toContain("No deal matches that closing.");
135 expect(await (await get("/?closing=week&side=buyer")).text()).toContain("No deal matches those filters.");
136 expect(await (await get("/?closing=week&q=zzz")).text()).toContain("No deal matches “zzz”.");
137 });
src/closing-window.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * Narrowing the clock by when a deal closes: this week, this month or later.
3 * Plain deals and today's date (in the agent's zone) in, plain deals out.
4 */
5
6 import { addDays } from "./dates.ts";
7
8 export const CLOSINGS = ["week", "month", "later"] as const;
9
10 /** What the clock can be narrowed to; "" is every deal. */
11 export type ClosingFilter = "" | (typeof CLOSINGS)[number];
12
13 /** The choices as the form names them, in order. */
14 export const CLOSING_CHOICES: readonly (readonly [ClosingFilter, string])[] = [
15 ["", "Any"],
16 ["week", "This week"],
17 ["month", "This month"],
18 ["later", "Later"],
19 ];
20
21 export const CLOSING_REFUSAL = "Closing is any, week, month or later.";
22
23 /** `value` as a filter: nothing or "any" is every deal; anything but week, month or later is undefined. */
24 export function closingFilter(value: string | null): ClosingFilter | undefined {
25 if (value === null || value === "" || value === "any") return "";
26 return CLOSINGS.find((c) => c === value);
27 }
28
29 /** The last day a closing counts as "this week": today and the six days after it. */
30 const weekEnd = (on: string): string => addDays(on, 6);
31
32 /** The last day of the calendar month `on` is in, or the week's end when the week runs past it. */
33 function monthEnd(on: string): string {
34 const first = `${on.slice(0, 7)}-01`;
35 const next = addDays(first, 31).slice(0, 7) + "-01";
36 const last = addDays(next, -1);
37 const week = weekEnd(on);
38 return last > week ? last : week;
39 }
40
41 /**
42 * The deals closing in the window. This week runs through six days from today
43 * (a closing already past is still this week's business); This month runs
44 * through the month's end and so includes this week; Later is everything after.
45 */
46 export function closingIn<T extends { readonly closing: string }>(deals: readonly T[], on: string, window: ClosingFilter): T[] {
47 if (window === "") return [...deals];
48 const week = weekEnd(on);
49 const month = monthEnd(on);
50 return deals.filter((d) => (window === "week" ? d.closing <= week : window === "month" ? d.closing <= month : d.closing > month));
51 }
src/pages/clock.ts +19 −12
@@ -18,8 +18,11 @@ import { byDay, lateNote, thisWeek, whereNote } from "../week.ts";
1818 import { layout } from "./layout.ts";
1919 import { mayRestore } from "../actions.ts";
2020 import { badge, draftLink, opens, owedBy, URGENCY_LABEL } from "./sheet.ts";
21 import { type ClosingFilter, closingIn } from "../closing-window.ts";
2122 import { find } from "../find.ts";
2223 import { onSide, type SideFilter } from "../side.ts";
24 import { closingFilterSelect } from "./closing-window.ts";
25 import { reviewHref } from "./review.ts";
2326 import { dealSideLabel, sideFilterSelect, sideLabel } from "./side.ts";
2427 import { memoizer, print } from "../memo.ts";
2528 import { bookLine, tally } from "../book.ts";
@@ -102,15 +105,15 @@ export function axis(start: string, days: number, on: string): string {
102105 }
103106
104107 /** The query a link to the week sheet carries to keep the clock's narrowing. */
105 const weekQuery = (q: string, side: SideFilter): string => {
106 const parts = [q === "" ? "" : `q=${encodeURIComponent(q)}`, side === "" ? "" : `side=${side}`].filter((p) => p !== "");
108 const weekQuery = (q: string, side: SideFilter, closing: ClosingFilter): string => {
109 const parts = [q === "" ? "" : `q=${encodeURIComponent(q)}`, side === "" ? "" : `side=${side}`, closing === "" ? "" : `closing=${closing}`].filter((p) => p !== "");
107110 return parts.length === 0 ? "" : `?${parts.join("&")}`;
108111 };
109112
110113 const weekRows = memoizer<string>(3000);
111114
112115 /** Open dates due within the week, under day headings; each row opens its sheet. */
113 export function weekCard(deals: readonly DealView[], on: string, q = "", side: SideFilter = ""): string {
116 export function weekCard(deals: readonly DealView[], on: string, q = "", side: SideFilter = "", closing: ClosingFilter = ""): string {
114117 const groups = byDay(thisWeek(deals, on), on);
115118 const body =
116119 groups.length === 0
@@ -127,7 +130,7 @@ export function weekCard(deals: readonly DealView[], on: string, q = "", side: S
127130 .join("");
128131 return `<section id="week" class="card" data-live="deal.changed">
129132 <h2>This week</h2>
130 <p class="small"><a href="/week${weekQuery(q, side)}">Print this week</a></p>
133 <p class="small"><a href="/week${weekQuery(q, side, closing)}">Print this week</a></p>
131134 ${body}
132135 </section>`;
133136 }
@@ -162,9 +165,10 @@ export function clock(input: {
162165 open?: number | undefined;
163166 q?: string | undefined;
164167 side?: SideFilter | undefined;
168 closing?: ClosingFilter | undefined;
165169 }): string {
166170 const key = Bun.hash(
167 JSON.stringify([input.who, input.on, input.notice ?? null, input.open ?? null, input.q ?? "", input.side ?? "", input.agent, input.waiting, input.tour]) +
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]) +
168172 input.deals.map(print).join(),
169173 );
170174 return pages(`${input.deals.length}:${key}`, () => render(input));
@@ -183,6 +187,7 @@ function render(input: {
183187 open?: number | undefined;
184188 q?: string | undefined;
185189 side?: SideFilter | undefined;
190 closing?: ClosingFilter | undefined;
186191 }): string {
187192 const { who, on, notice } = input;
188193 const agent = input.agent ?? defaults;
@@ -190,7 +195,8 @@ function render(input: {
190195 input.open === undefined ? undefined : input.deals.flatMap((d) => d.sent).find((m) => m.id === input.open);
191196 const q = input.q ?? "";
192197 const side = input.side ?? "";
193 const found = onSide(find(input.deals, q), side);
198 const closing = input.closing ?? "";
199 const found = closingIn(onSide(find(input.deals, q), side), on, closing);
194200 const deals = found.filter((deal) => !deal.closed && !deal.fell_through);
195201 const closed = found
196202 .filter((deal) => deal.closed && !deal.fell_through)
@@ -205,11 +211,11 @@ function render(input: {
205211 // A closed deal is not on the clock, but its check-in is a reminder for today.
206212 const today = [...deals, ...closed].map((deal) => reminderRows(deal, on, agent));
207213 const todayCount = today.reduce((n, t) => n + t.count, 0);
208 const none = (q !== "" || side !== "") && found.length === 0;
214 const none = (q !== "" || side !== "" || closing !== "") && found.length === 0;
209215 const toSend = today.reduce((n, t) => n + t.toSend, 0);
210216 const nothing =
211 q !== "" || side !== ""
212 ? `<p class="empty">No deal matches${q === "" ? " that side" : ` “${escape(q)}”`}. <a href="/">Clear</a></p>`
217 q !== "" || side !== "" || closing !== ""
218 ? `<p class="empty">No deal matches${q !== "" ? ` “${escape(q)}”` : closing !== "" && side !== "" ? " those filters" : closing !== "" ? " that closing" : " that side"}. <a href="/">Clear</a></p>`
213219 : `<p class="empty">No deals yet. To add your first deal, choose <b>New deal</b> and enter the address, the clients and the contract and closing dates; its clock starts at once. <a href="/deals/new">Open the first one</a>.</p>`;
214220 const timeline =
215221 deals.length === 0 && closed.length === 0 && fell.length === 0
@@ -254,7 +260,8 @@ function render(input: {
254260 <label for="find-q">Find a deal</label>
255261 <input id="find-q" class="input" type="search" name="q" value="${escape(q)}" maxlength="100" placeholder="Address, client or party" autocomplete="off">
256262 ${sideFilterSelect(side)}
257 <button class="button button-quiet">Find</button>${q === "" && side === "" ? "" : ` <a href="/">Clear</a>`}
263 ${closingFilterSelect(closing)}
264 <button class="button button-quiet">Find</button>${q === "" && side === "" && closing === "" ? "" : ` <a href="/">Clear</a>`}
258265 </form>
259266 <section id="clock" class="card clock" data-live="deal.changed">
260267 ${timeline}
@@ -268,10 +275,10 @@ function render(input: {
268275 </section>
269276 ${closedCard}
270277 ${restoreCard}
271 ${deals.length === 0 ? "" : weekCard(deals, on, q, side)}
278 ${deals.length === 0 ? "" : weekCard(deals, on, q, side, closing)}
272279 ${none ? "" : `<section id="today" class="card" data-live="deal.changed">
273280 <h2>Reminders for today <span class="muted">· ${toSend === 0 ? "all sent" : `${toSend} to send`}</span></h2>
274 ${toSend === 0 ? "" : `<p><a class="button" href="/reminders/today${q === "" ? "" : `?q=${encodeURIComponent(q)}`}">Review all</a></p>`}
281 ${toSend === 0 ? "" : `<p><a class="button" href="${reviewHref(q, 0, 0, closing)}">Review all</a></p>`}
275282 ${
276283 todayCount === 0
277284 ? `<p class="muted">Nothing goes out today.</p>`
src/pages/closing-window.ts added +8
@@ -0,0 +1,8 @@
1 /** The clock's Closing filter: a select that is part of the Find form, so it narrows without script. */
2
3 import { escape } from "../http.ts";
4 import { CLOSING_CHOICES, type ClosingFilter } from "../closing-window.ts";
5
6 export const closingFilterSelect = (closing: ClosingFilter): string =>
7 `<label for="find-closing">Closing</label>
8 <select id="find-closing" class="input" name="closing">${CLOSING_CHOICES.map(([v, name]) => `<option value="${v}"${v === closing ? " selected" : ""}>${escape(name)}</option>`).join("")}</select>`;
src/pages/review.ts +11 −6
@@ -12,6 +12,7 @@ import { BODY_MAX, EMAIL_MAX, PHONE_MAX, REASON_MAX, reachLine, SUBJECT_MAX } fr
1212 import { addDays, dayOf, spoken } from "../dates.ts";
1313 import { PARTY_LABEL } from "../deadlines.ts";
1414 import { asset, escape } from "../http.ts";
15 import type { ClosingFilter } from "../closing-window.ts";
1516 import { heldFrom, momentLabel, type Draft } from "../reminders.ts";
1617 import type { Who } from "../who.ts";
1718 import { layout } from "./layout.ts";
@@ -21,9 +22,10 @@ import { settledLine, type Settled } from "../settled.ts";
2122 export const REVIEW_PATH = "/reminders/today";
2223
2324 /** The review page's address: the Find query, how many were skipped and how many settled in this round. */
24 export function reviewHref(q: string, at: number, done: number): string {
25 export function reviewHref(q: string, at: number, done: number, closing: ClosingFilter = ""): string {
2526 const params = new URLSearchParams();
2627 if (q !== "") params.set("q", q);
28 if (closing !== "") params.set("closing", closing);
2729 if (at > 0) params.set("at", String(at));
2830 if (done > 0) params.set("done", String(done));
2931 const s = params.toString();
@@ -60,6 +62,7 @@ export function reviewPage(input: {
6062 /** The drafts still to send, in the clock's order. */
6163 queue: readonly Draft[];
6264 q: string;
65 closing?: ClosingFilter;
6366 /** How many of the queue this round has skipped past. */
6467 at: number;
6568 /** How many this round has sent or declined. */
@@ -74,13 +77,15 @@ export function reviewPage(input: {
7477 typed?: Typed | undefined;
7578 }): string {
7679 const { who, agent, queue, q, done, error, typed } = input;
80 const closing = input.closing ?? "";
7781 const at = queue.length === 0 ? 0 : input.at % queue.length;
7882 const draft = queue[at];
79 const back = `<a href="/${q === "" ? "" : `?q=${encodeURIComponent(q)}`}">Back to the clock</a>`;
83 const kept = [q === "" ? "" : `q=${encodeURIComponent(q)}`, closing === "" ? "" : `closing=${closing}`].filter((p) => p !== "");
84 const back = `<a href="/${kept.length === 0 ? "" : `?${kept.join("&")}`}">Back to the clock</a>`;
8085 const body =
8186 draft === undefined
8287 ? `<section class="card narrow"><h1>Review today's reminders</h1><p>${input.settled === undefined || settledLine(input.settled) === "" ? "All of today's reminders are sent or declined." : `All of today's reminders are sent or declined. ${escape(settledLine(input.settled))}`}</p>${nextLine(input.next)}<p>${back}</p></section>`
83 : form(draft, { q, at, done, position: done + at + 1, total: done + queue.length, error, typed, on: dayOf(agent ?? {}) }, back);
88 : form(draft, { q, closing, at, done, position: done + at + 1, total: done + queue.length, error, typed, on: dayOf(agent ?? {}) }, back);
8489 return layout({ body, who, agent, waiting: input.waiting, tour: input.tour, path: REVIEW_PATH, heading: "Review today's reminders", notice: input.notice, open: input.open, live: false });
8590 }
8691
@@ -106,16 +111,16 @@ function contactField(typed: Typed, problem: string, invalid: string): string {
106111
107112 function form(
108113 draft: Draft,
109 s: { q: string; at: number; done: number; position: number; total: number; error: ReviewError | undefined; typed: Typed | undefined; on: string },
114 s: { q: string; closing: ClosingFilter; at: number; done: number; position: number; total: number; error: ReviewError | undefined; typed: Typed | undefined; on: string },
110115 back: string,
111116 ): string {
112117 const { error, on } = s;
113118 const typed = s.typed ?? { subject: draft.subject, body: draft.body, reason: "" };
114 const keys = { deadline: draft.deadline_id, party: draft.party_id, moment: draft.moment, at: s.at, done: s.done, q: s.q };
119 const keys = { deadline: draft.deadline_id, party: draft.party_id, moment: draft.moment, at: s.at, done: s.done, q: s.q, closing: s.closing };
115120 const problem = (field: ReviewField): string =>
116121 error?.field === field ? `<p class="conflict" role="alert"><b>${FIELD_LABEL[field]}:</b> ${escape(error.message)}</p>` : "";
117122 const invalid = (field: ReviewField): string => (error?.field === field ? ' aria-invalid="true"' : "");
118 const skip = s.total - s.done > 1 ? reviewHref(s.q, s.at + 1, s.done) : "";
123 const skip = s.total - s.done > 1 ? reviewHref(s.q, s.at + 1, s.done, s.closing) : "";
119124 return `<section class="card narrow" tabindex="0" data-keys aria-describedby="review-keys">
120125 ${s.done === 0 && s.at === 0 ? "<p><b>Start here</b></p>\n " : ""}<p class="eyebrow">${s.position} of ${s.total}</p>
121126 <h1>Review today's reminders</h1>
src/pages/week.ts +4 −2
@@ -5,6 +5,7 @@
55 * only shows the Print button. Printing rules are the client timeline's.
66 */
77
8 import type { ClosingFilter } from "../closing-window.ts";
89 import { urgencyOf, spoken } from "../dates.ts";
910 import type { DealView } from "../deals.ts";
1011 import type { Agent } from "../reminders.ts";
@@ -23,12 +24,13 @@ const closingAt = (deal: DealView, d: DealView["deadlines"][number]): string =>
2324 return text === "" ? "" : ` · ${escape(text)}`;
2425 };
2526
26 export function weekPage(input: { who: Who; agent?: Agent | undefined; deals: readonly DealView[]; on: string; q?: string; side?: SideFilter }): string {
27 export function weekPage(input: { who: Who; agent?: Agent | undefined; deals: readonly DealView[]; on: string; q?: string; side?: SideFilter; closing?: ClosingFilter }): string {
2728 const { who, deals, on } = input;
2829 const agent = input.agent ?? defaults;
2930 const q = input.q ?? "";
3031 const side = input.side ?? "";
31 const kept = [q === "" ? "" : `q=${encodeURIComponent(q)}`, side === "" ? "" : `side=${side}`].filter((p) => p !== "");
32 const closing = input.closing ?? "";
33 const kept = [q === "" ? "" : `q=${encodeURIComponent(q)}`, side === "" ? "" : `side=${side}`, closing === "" ? "" : `closing=${closing}`].filter((p) => p !== "");
3234 const back = kept.length === 0 ? "/" : `/?${kept.join("&")}`;
3335 const groups = byDay(thisWeek(deals, on), on);
3436 const days =
src/routes.ts +29 −16
@@ -105,6 +105,7 @@ import { permit } from "./contract.ts";
105105 import type { AuditRow } from "./audit.ts";
106106 import { rpc } from "./mcp.ts";
107107 import { FIND_REFUSAL, find, query } from "./find.ts";
108 import { CLOSING_REFUSAL, type ClosingFilter, closingFilter, closingIn } from "./closing-window.ts";
108109 import { SIDES, SIDE_REFUSAL, onSide, sideFilter } from "./side.ts";
109110 import { clock } from "./pages/clock.ts";
110111 import { dealSheets } from "./pages/sheet.ts";
@@ -634,10 +635,10 @@ const unconfirmed = (c: Context, path: string): Response =>
634635 );
635636
636637 /** The drafts still to send today, in the clock's order, narrowed by the Find query; or the refusal. */
637 function reviewQueue(c: Context, q: string): Result<Draft[]> {
638 function reviewQueue(c: Context, q: string, closing: ClosingFilter = ""): Result<Draft[]> {
638639 const deals = listDeals(c.db, c.who);
639640 if (!deals.ok) return deals;
640 return { ok: true, value: todaysDrafts(find(deals.value, q), dayOf(agentFor(c)), agentFor(c)).filter(unsent) };
641 return { ok: true, value: todaysDrafts(closingIn(find(deals.value, q), dayOf(agentFor(c)), closing), dayOf(agentFor(c)), agentFor(c)).filter(unsent) };
641642 }
642643
643644 /** The soonest reminder still to come among the deals this person may list; only asked for on the finished screen. */
@@ -677,27 +678,28 @@ async function reviewed(c: Context, act: (f: Record<string, string>) => Result<{
677678 const f = await form(c);
678679 if (f instanceof Response) return f;
679680 const q = query(f.q ?? "") ?? "";
681 const closing = closingFilter(f.closing ?? "") ?? "";
680682 const at = count(f.at);
681683 const settled = count(f.done);
682684 const said = typeof saying === "string" ? saying : saying(f);
683685 const result = act(f);
684 if (result.ok) return back(said, reviewHref(q, at, settled + 1), said === SENT ? result.value.id : undefined);
686 if (result.ok) return back(said, reviewHref(q, at, settled + 1, closing), said === SENT ? result.value.id : undefined);
685687 if (result.error === "signin_required" || result.error === "forbidden")
686 return html(refusedPage({ who: c.who, ...headerFor(c), agent: agentFor(c), path: reviewHref(q, at, settled), heading: "Review today's reminders", error: result.error, message: result.message }), STATUS[result.error]);
687 const shown = sameDraft(c, f, q, settled, result.error === "invalid" ? { field: fieldOf(result.message), message: result.message } : undefined);
688 if (result.error !== "invalid" || shown === undefined) return back(result.message, reviewHref(q, at, settled));
688 return html(refusedPage({ who: c.who, ...headerFor(c), agent: agentFor(c), path: reviewHref(q, at, settled, closing), heading: "Review today's reminders", error: result.error, message: result.message }), STATUS[result.error]);
689 const shown = sameDraft(c, f, q, closing, settled, result.error === "invalid" ? { field: fieldOf(result.message), message: result.message } : undefined);
690 if (result.error !== "invalid" || shown === undefined) return back(result.message, reviewHref(q, at, settled, closing));
689691 return html(shown, STATUS.invalid);
690692 }
691693
692694 /** The review page at the draft a form was posted for, with what was typed kept; undefined when that draft is no longer in the queue. */
693 function sameDraft(c: Context, f: Record<string, string>, q: string, settled: number, error: ReviewError | undefined): string | undefined {
694 const queue = reviewQueue(c, q);
695 function sameDraft(c: Context, f: Record<string, string>, q: string, closing: ClosingFilter, settled: number, error: ReviewError | undefined): string | undefined {
696 const queue = reviewQueue(c, q, closing);
695697 const same = queue.ok
696698 ? queue.value.findIndex((d) => d.deadline_id === Number(f.deadline) && d.party_id === Number(f.party) && d.moment === f.moment)
697699 : -1;
698700 if (!queue.ok || same === -1) return undefined;
699701 const typed = { subject: f.subject ?? "", body: f.body ?? "", reason: f.reason ?? "", email: f.email ?? "", phone: f.phone ?? "" };
700 return reviewPage({ who: c.who, ...headerFor(c), agent: agentFor(c), queue: queue.value, q, at: same, done: settled, error, typed });
702 return reviewPage({ who: c.who, ...headerFor(c), agent: agentFor(c), queue: queue.value, q, closing, at: same, done: settled, error, typed });
701703 }
702704
703705 /**
@@ -709,6 +711,7 @@ async function reviewedContact(c: Context): Promise<Response> {
709711 const f = await form(c);
710712 if (f instanceof Response) return f;
711713 const q = query(f.q ?? "") ?? "";
714 const closing = closingFilter(f.closing ?? "") ?? "";
712715 const at = count(f.at);
713716 const settled = count(f.done);
714717 const party = Number(f.party);
@@ -717,9 +720,9 @@ async function reviewedContact(c: Context): Promise<Response> {
717720 ? { ok: false, error: "invalid", message: "Add an email or phone to save." }
718721 : editParty(c.db, c.who, { id: party, name: nameOf(c.db, "party", party), email: f.email, phone: f.phone });
719722 if (!result.ok && (result.error === "signin_required" || result.error === "forbidden"))
720 return html(refusedPage({ who: c.who, ...headerFor(c), agent: agentFor(c), path: reviewHref(q, at, settled), heading: "Review today's reminders", error: result.error, message: result.message }), STATUS[result.error]);
721 const shown = sameDraft(c, f, q, settled, result.ok ? undefined : { field: "contact", message: result.message });
722 if (shown === undefined) return back(result.ok ? "Contact saved." : result.message, reviewHref(q, at, settled));
723 return html(refusedPage({ who: c.who, ...headerFor(c), agent: agentFor(c), path: reviewHref(q, at, settled, closing), heading: "Review today's reminders", error: result.error, message: result.message }), STATUS[result.error]);
724 const shown = sameDraft(c, f, q, closing, settled, result.ok ? undefined : { field: "contact", message: result.message });
725 if (shown === undefined) return back(result.ok ? "Contact saved." : result.message, reviewHref(q, at, settled, closing));
723726 return html(shown, result.ok ? 200 : STATUS.invalid);
724727 }
725728
@@ -816,7 +819,10 @@ export const routes: readonly Route[] = [
816819 const side = sideFilter(c.url.searchParams.get("side"));
817820 if (side === undefined)
outdated

Also pass closing to /reminders/today (and the Review all link in clock.ts) so today's reminders narrow as the issue asks.

818821 return html(refusedPage({ who: c.who, agent: agentFor(c), path: "/", heading: "The clock", error: "invalid", message: SIDE_REFUSAL }), 422, clear);
819 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 }), 200, clear);
822 const closing = closingFilter(c.url.searchParams.get("closing"));
823 if (closing === undefined)
824 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);
820826 },
821827 },
822828 {
@@ -838,7 +844,11 @@ export const routes: readonly Route[] = [
838844 const side = sideFilter(c.url.searchParams.get("side"));
839845 if (side === undefined)
840846 return html(refusedPage({ who: c.who, agent: agentFor(c), path: "/week", heading: "This week", error: "invalid", message: SIDE_REFUSAL }), 422);
841 return html(weekPage({ who: c.who, agent: agentFor(c), deals: onSide(find(deals.value, q), side), on: dayOf(agentFor(c)), q, side }));
847 const closing = closingFilter(c.url.searchParams.get("closing"));
848 if (closing === undefined)
849 return html(refusedPage({ who: c.who, agent: agentFor(c), path: "/week", heading: "This week", error: "invalid", message: CLOSING_REFUSAL }), 422);
850 const on = dayOf(agentFor(c));
851 return html(weekPage({ who: c.who, agent: agentFor(c), deals: closingIn(onSide(find(deals.value, q), side), on, closing), on, q, side, closing }));
842852 },
843853 },
844854 {
@@ -1555,7 +1565,10 @@ export const routes: readonly Route[] = [
15551565 const q = query(c.url.searchParams.get("q"));
15561566 if (q === undefined)
15571567 return html(refusedPage({ who: c.who, ...headerFor(c), agent: agentFor(c), path: "/reminders/today", heading: "Review today's reminders", error: "invalid", message: FIND_REFUSAL }), 400, clear);
1558 const queue = reviewQueue(c, q);
1568 const closing = closingFilter(c.url.searchParams.get("closing"));
1569 if (closing === undefined)
1570 return html(refusedPage({ who: c.who, ...headerFor(c), agent: agentFor(c), path: "/reminders/today", heading: "Review today's reminders", error: "invalid", message: CLOSING_REFUSAL }), 400, clear);
1571 const queue = reviewQueue(c, q, closing);
15591572 if (!queue.ok)
15601573 return html(
15611574 refusedPage({ who: c.who, ...headerFor(c), agent: agentFor(c), path: "/reminders/today", heading: "Review today's reminders", error: queue.error, message: queue.message }),
@@ -1565,7 +1578,7 @@ export const routes: readonly Route[] = [
15651578 const sent = open === undefined ? undefined : listDeals(c.db, c.who);
15661579 const link = open === undefined || !sent?.ok ? undefined : sent.value.flatMap((d) => d.sent).find((m) => m.id === open)?.hrefs ?? [];
15671580 return html(
1568 reviewPage({ who: c.who, ...headerFor(c), agent: agentFor(c), queue: queue.value, q, at: count(c.url.searchParams.get("at")), done: count(c.url.searchParams.get("done")), notice, open: link, settled: queue.value.length === 0 ? settledOn(c.db, agentFor(c).zone ?? DEFAULT_ZONE, dayOf(agentFor(c))) : undefined, next: queue.value.length === 0 ? nextAfter(c) : undefined }),
1581 reviewPage({ who: c.who, ...headerFor(c), agent: agentFor(c), queue: queue.value, q, closing, at: count(c.url.searchParams.get("at")), done: count(c.url.searchParams.get("done")), notice, open: link, settled: queue.value.length === 0 ? settledOn(c.db, agentFor(c).zone ?? DEFAULT_ZONE, dayOf(agentFor(c))) : undefined, next: queue.value.length === 0 ? nextAfter(c) : undefined }),
15691582 200,
15701583 clear,
15711584 );
src/speed.test.ts +3 −1
@@ -133,6 +133,8 @@ const routes: [string, () => string][] = [
133133 // The Closed list is the clock's lower section: the fixture closes some deals.
134134 ["the clock with closed deals", () => "/?closed=1"],
135135 ["activity (as admin)", () => "/activity"],
136 ["the clock narrowed to this month", () => "/?closing=month"],
137 ["the printed week narrowed to this week", () => "/week?closing=week"],
136138 ];
137139
138140 test.each(routes)("%s answers in under 50 ms with 200 deals", async (_name, path) => {
@@ -194,7 +196,7 @@ describe("the sample book", () => {
194196 return mid(times);
195197 }
196198
197 test.each(["/", "/reminders/today", "/week", "/people", "/profile"])("%s answers in under 50 ms on the sample book", async (path) => {
199 test.each(["/", "/?closing=month", "/week?closing=week", "/reminders/today", "/week", "/people", "/profile"])("%s answers in under 50 ms on the sample book", async (path) => {
198200 forgetRationing();
199201 const took = await median(path);
200202 expect(took).toBeLessThan(BUDGET_MS);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs