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
Check · Review · Land · Live done — Landed and live in production.
mason/deal-clock:mason/309-the-agent-can-narrow-the-clock-to-deals → main 11 commits fork 1 thread · 1 unresolved
11 files
changed +290
−48
.gitignore +14
| @@ -2,3 +2,17 @@ node_modules/ | ||
| 2 | 2 | *.db |
| 3 | 3 | *.db-wal |
| 4 | 4 | *.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`. | ||
| 127 | 127 | |
| 128 | 128 | ## Finding a deal |
| 129 | 129 | |
| 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. | |
| 141 | 143 | |
| 142 | 144 | ## Opening and shaping a deal |
| 143 | 145 |
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/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` | | |
| 281 | 282 | | `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 | |
| 282 | 283 | | `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 | |
| 283 | 284 | | `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"; | ||
| 18 | 18 | import { layout } from "./layout.ts"; |
| 19 | 19 | import { mayRestore } from "../actions.ts"; |
| 20 | 20 | import { badge, draftLink, opens, owedBy, URGENCY_LABEL } from "./sheet.ts"; |
| 21 | import { type ClosingFilter, closingIn } from "../closing-window.ts"; | |
| 21 | 22 | import { find } from "../find.ts"; |
| 22 | 23 | import { onSide, type SideFilter } from "../side.ts"; |
| 24 | import { closingFilterSelect } from "./closing-window.ts"; | |
| 25 | import { reviewHref } from "./review.ts"; | |
| 23 | 26 | import { dealSideLabel, sideFilterSelect, sideLabel } from "./side.ts"; |
| 24 | 27 | import { memoizer, print } from "../memo.ts"; |
| 25 | 28 | import { bookLine, tally } from "../book.ts"; |
| @@ -102,15 +105,15 @@ export function axis(start: string, days: number, on: string): string { | ||
| 102 | 105 | } |
| 103 | 106 | |
| 104 | 107 | /** 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 !== ""); | |
| 107 | 110 | return parts.length === 0 ? "" : `?${parts.join("&")}`; |
| 108 | 111 | }; |
| 109 | 112 | |
| 110 | 113 | const weekRows = memoizer<string>(3000); |
| 111 | 114 | |
| 112 | 115 | /** 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 { | |
| 114 | 117 | const groups = byDay(thisWeek(deals, on), on); |
| 115 | 118 | const body = |
| 116 | 119 | groups.length === 0 |
| @@ -127,7 +130,7 @@ export function weekCard(deals: readonly DealView[], on: string, q = "", side: S | ||
| 127 | 130 | .join(""); |
| 128 | 131 | return `<section id="week" class="card" data-live="deal.changed"> |
| 129 | 132 | <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> | |
| 131 | 134 | ${body} |
| 132 | 135 | </section>`; |
| 133 | 136 | } |
| @@ -162,9 +165,10 @@ export function clock(input: { | ||
| 162 | 165 | open?: number | undefined; |
| 163 | 166 | q?: string | undefined; |
| 164 | 167 | side?: SideFilter | undefined; |
| 168 | closing?: ClosingFilter | undefined; | |
| 165 | 169 | }): string { |
| 166 | 170 | 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]) + | |
| 168 | 172 | input.deals.map(print).join(), |
| 169 | 173 | ); |
| 170 | 174 | return pages(`${input.deals.length}:${key}`, () => render(input)); |
| @@ -183,6 +187,7 @@ function render(input: { | ||
| 183 | 187 | open?: number | undefined; |
| 184 | 188 | q?: string | undefined; |
| 185 | 189 | side?: SideFilter | undefined; |
| 190 | closing?: ClosingFilter | undefined; | |
| 186 | 191 | }): string { |
| 187 | 192 | const { who, on, notice } = input; |
| 188 | 193 | const agent = input.agent ?? defaults; |
| @@ -190,7 +195,8 @@ function render(input: { | ||
| 190 | 195 | input.open === undefined ? undefined : input.deals.flatMap((d) => d.sent).find((m) => m.id === input.open); |
| 191 | 196 | const q = input.q ?? ""; |
| 192 | 197 | 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); | |
| 194 | 200 | const deals = found.filter((deal) => !deal.closed && !deal.fell_through); |
| 195 | 201 | const closed = found |
| 196 | 202 | .filter((deal) => deal.closed && !deal.fell_through) |
| @@ -205,11 +211,11 @@ function render(input: { | ||
| 205 | 211 | // A closed deal is not on the clock, but its check-in is a reminder for today. |
| 206 | 212 | const today = [...deals, ...closed].map((deal) => reminderRows(deal, on, agent)); |
| 207 | 213 | 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; | |
| 209 | 215 | const toSend = today.reduce((n, t) => n + t.toSend, 0); |
| 210 | 216 | 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>` | |
| 213 | 219 | : `<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>`; |
| 214 | 220 | const timeline = |
| 215 | 221 | deals.length === 0 && closed.length === 0 && fell.length === 0 |
| @@ -254,7 +260,8 @@ function render(input: { | ||
| 254 | 260 | <label for="find-q">Find a deal</label> |
| 255 | 261 | <input id="find-q" class="input" type="search" name="q" value="${escape(q)}" maxlength="100" placeholder="Address, client or party" autocomplete="off"> |
| 256 | 262 | ${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>`} | |
| 258 | 265 | </form> |
| 259 | 266 | <section id="clock" class="card clock" data-live="deal.changed"> |
| 260 | 267 | ${timeline} |
| @@ -268,10 +275,10 @@ function render(input: { | ||
| 268 | 275 | </section> |
| 269 | 276 | ${closedCard} |
| 270 | 277 | ${restoreCard} |
| 271 | ${deals.length === 0 ? "" : weekCard(deals, on, q, side)} | |
| 278 | ${deals.length === 0 ? "" : weekCard(deals, on, q, side, closing)} | |
| 272 | 279 | ${none ? "" : `<section id="today" class="card" data-live="deal.changed"> |
| 273 | 280 | <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>`} | |
| 275 | 282 | ${ |
| 276 | 283 | todayCount === 0 |
| 277 | 284 | ? `<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 | ||
| 12 | 12 | import { addDays, dayOf, spoken } from "../dates.ts"; |
| 13 | 13 | import { PARTY_LABEL } from "../deadlines.ts"; |
| 14 | 14 | import { asset, escape } from "../http.ts"; |
| 15 | import type { ClosingFilter } from "../closing-window.ts"; | |
| 15 | 16 | import { heldFrom, momentLabel, type Draft } from "../reminders.ts"; |
| 16 | 17 | import type { Who } from "../who.ts"; |
| 17 | 18 | import { layout } from "./layout.ts"; |
| @@ -21,9 +22,10 @@ import { settledLine, type Settled } from "../settled.ts"; | ||
| 21 | 22 | export const REVIEW_PATH = "/reminders/today"; |
| 22 | 23 | |
| 23 | 24 | /** 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 { | |
| 25 | 26 | const params = new URLSearchParams(); |
| 26 | 27 | if (q !== "") params.set("q", q); |
| 28 | if (closing !== "") params.set("closing", closing); | |
| 27 | 29 | if (at > 0) params.set("at", String(at)); |
| 28 | 30 | if (done > 0) params.set("done", String(done)); |
| 29 | 31 | const s = params.toString(); |
| @@ -60,6 +62,7 @@ export function reviewPage(input: { | ||
| 60 | 62 | /** The drafts still to send, in the clock's order. */ |
| 61 | 63 | queue: readonly Draft[]; |
| 62 | 64 | q: string; |
| 65 | closing?: ClosingFilter; | |
| 63 | 66 | /** How many of the queue this round has skipped past. */ |
| 64 | 67 | at: number; |
| 65 | 68 | /** How many this round has sent or declined. */ |
| @@ -74,13 +77,15 @@ export function reviewPage(input: { | ||
| 74 | 77 | typed?: Typed | undefined; |
| 75 | 78 | }): string { |
| 76 | 79 | const { who, agent, queue, q, done, error, typed } = input; |
| 80 | const closing = input.closing ?? ""; | |
| 77 | 81 | const at = queue.length === 0 ? 0 : input.at % queue.length; |
| 78 | 82 | 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>`; | |
| 80 | 85 | const body = |
| 81 | 86 | draft === undefined |
| 82 | 87 | ? `<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); | |
| 84 | 89 | 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 }); |
| 85 | 90 | } |
| 86 | 91 | |
| @@ -106,16 +111,16 @@ function contactField(typed: Typed, problem: string, invalid: string): string { | ||
| 106 | 111 | |
| 107 | 112 | function form( |
| 108 | 113 | 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 }, | |
| 110 | 115 | back: string, |
| 111 | 116 | ): string { |
| 112 | 117 | const { error, on } = s; |
| 113 | 118 | 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 }; | |
| 115 | 120 | const problem = (field: ReviewField): string => |
| 116 | 121 | error?.field === field ? `<p class="conflict" role="alert"><b>${FIELD_LABEL[field]}:</b> ${escape(error.message)}</p>` : ""; |
| 117 | 122 | 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) : ""; | |
| 119 | 124 | return `<section class="card narrow" tabindex="0" data-keys aria-describedby="review-keys"> |
| 120 | 125 | ${s.done === 0 && s.at === 0 ? "<p><b>Start here</b></p>\n " : ""}<p class="eyebrow">${s.position} of ${s.total}</p> |
| 121 | 126 | <h1>Review today's reminders</h1> |
src/pages/week.ts +4 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * only shows the Print button. Printing rules are the client timeline's. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import type { ClosingFilter } from "../closing-window.ts"; | |
| 8 | 9 | import { urgencyOf, spoken } from "../dates.ts"; |
| 9 | 10 | import type { DealView } from "../deals.ts"; |
| 10 | 11 | import type { Agent } from "../reminders.ts"; |
| @@ -23,12 +24,13 @@ const closingAt = (deal: DealView, d: DealView["deadlines"][number]): string => | ||
| 23 | 24 | return text === "" ? "" : ` · ${escape(text)}`; |
| 24 | 25 | }; |
| 25 | 26 | |
| 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 { | |
| 27 | 28 | const { who, deals, on } = input; |
| 28 | 29 | const agent = input.agent ?? defaults; |
| 29 | 30 | const q = input.q ?? ""; |
| 30 | 31 | 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 !== ""); | |
| 32 | 34 | const back = kept.length === 0 ? "/" : `/?${kept.join("&")}`; |
| 33 | 35 | const groups = byDay(thisWeek(deals, on), on); |
| 34 | 36 | const days = |
src/routes.ts +29 −16
| @@ -105,6 +105,7 @@ import { permit } from "./contract.ts"; | ||
| 105 | 105 | import type { AuditRow } from "./audit.ts"; |
| 106 | 106 | import { rpc } from "./mcp.ts"; |
| 107 | 107 | import { FIND_REFUSAL, find, query } from "./find.ts"; |
| 108 | import { CLOSING_REFUSAL, type ClosingFilter, closingFilter, closingIn } from "./closing-window.ts"; | |
| 108 | 109 | import { SIDES, SIDE_REFUSAL, onSide, sideFilter } from "./side.ts"; |
| 109 | 110 | import { clock } from "./pages/clock.ts"; |
| 110 | 111 | import { dealSheets } from "./pages/sheet.ts"; |
| @@ -634,10 +635,10 @@ const unconfirmed = (c: Context, path: string): Response => | ||
| 634 | 635 | ); |
| 635 | 636 | |
| 636 | 637 | /** 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[]> { | |
| 638 | 639 | const deals = listDeals(c.db, c.who); |
| 639 | 640 | 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) }; | |
| 641 | 642 | } |
| 642 | 643 | |
| 643 | 644 | /** 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<{ | ||
| 677 | 678 | const f = await form(c); |
| 678 | 679 | if (f instanceof Response) return f; |
| 679 | 680 | const q = query(f.q ?? "") ?? ""; |
| 681 | const closing = closingFilter(f.closing ?? "") ?? ""; | |
| 680 | 682 | const at = count(f.at); |
| 681 | 683 | const settled = count(f.done); |
| 682 | 684 | const said = typeof saying === "string" ? saying : saying(f); |
| 683 | 685 | 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); | |
| 685 | 687 | 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)); | |
| 689 | 691 | return html(shown, STATUS.invalid); |
| 690 | 692 | } |
| 691 | 693 | |
| 692 | 694 | /** 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); | |
| 695 | 697 | const same = queue.ok |
| 696 | 698 | ? queue.value.findIndex((d) => d.deadline_id === Number(f.deadline) && d.party_id === Number(f.party) && d.moment === f.moment) |
| 697 | 699 | : -1; |
| 698 | 700 | if (!queue.ok || same === -1) return undefined; |
| 699 | 701 | 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 }); | |
| 701 | 703 | } |
| 702 | 704 | |
| 703 | 705 | /** |
| @@ -709,6 +711,7 @@ async function reviewedContact(c: Context): Promise<Response> { | ||
| 709 | 711 | const f = await form(c); |
| 710 | 712 | if (f instanceof Response) return f; |
| 711 | 713 | const q = query(f.q ?? "") ?? ""; |
| 714 | const closing = closingFilter(f.closing ?? "") ?? ""; | |
| 712 | 715 | const at = count(f.at); |
| 713 | 716 | const settled = count(f.done); |
| 714 | 717 | const party = Number(f.party); |
| @@ -717,9 +720,9 @@ async function reviewedContact(c: Context): Promise<Response> { | ||
| 717 | 720 | ? { ok: false, error: "invalid", message: "Add an email or phone to save." } |
| 718 | 721 | : editParty(c.db, c.who, { id: party, name: nameOf(c.db, "party", party), email: f.email, phone: f.phone }); |
| 719 | 722 | 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)); | |
| 723 | 726 | return html(shown, result.ok ? 200 : STATUS.invalid); |
| 724 | 727 | } |
| 725 | 728 | |
| @@ -816,7 +819,10 @@ export const routes: readonly Route[] = [ | ||
| 816 | 819 | const side = sideFilter(c.url.searchParams.get("side")); |
| 817 | 820 | if (side === undefined) |
| 818 | 821 | 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); | |
| 820 | 826 | }, |
| 821 | 827 | }, |
| 822 | 828 | { |
| @@ -838,7 +844,11 @@ export const routes: readonly Route[] = [ | ||
| 838 | 844 | const side = sideFilter(c.url.searchParams.get("side")); |
| 839 | 845 | if (side === undefined) |
| 840 | 846 | 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 })); | |
| 842 | 852 | }, |
| 843 | 853 | }, |
| 844 | 854 | { |
| @@ -1555,7 +1565,10 @@ export const routes: readonly Route[] = [ | ||
| 1555 | 1565 | const q = query(c.url.searchParams.get("q")); |
| 1556 | 1566 | if (q === undefined) |
| 1557 | 1567 | 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); | |
| 1559 | 1572 | if (!queue.ok) |
| 1560 | 1573 | return html( |
| 1561 | 1574 | 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[] = [ | ||
| 1565 | 1578 | const sent = open === undefined ? undefined : listDeals(c.db, c.who); |
| 1566 | 1579 | const link = open === undefined || !sent?.ok ? undefined : sent.value.flatMap((d) => d.sent).find((m) => m.id === open)?.hrefs ?? []; |
| 1567 | 1580 | 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 }), | |
| 1569 | 1582 | 200, |
| 1570 | 1583 | clear, |
| 1571 | 1584 | ); |
src/speed.test.ts +3 −1
| @@ -133,6 +133,8 @@ const routes: [string, () => string][] = [ | ||
| 133 | 133 | // The Closed list is the clock's lower section: the fixture closes some deals. |
| 134 | 134 | ["the clock with closed deals", () => "/?closed=1"], |
| 135 | 135 | ["activity (as admin)", () => "/activity"], |
| 136 | ["the clock narrowed to this month", () => "/?closing=month"], | |
| 137 | ["the printed week narrowed to this week", () => "/week?closing=week"], | |
| 136 | 138 | ]; |
| 137 | 139 | |
| 138 | 140 | test.each(routes)("%s answers in under 50 ms with 200 deals", async (_name, path) => { |
| @@ -194,7 +196,7 @@ describe("the sample book", () => { | ||
| 194 | 196 | return mid(times); |
| 195 | 197 | } |
| 196 | 198 | |
| 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) => { | |
| 198 | 200 | forgetRationing(); |
| 199 | 201 | const took = await median(path); |
| 200 | 202 | expect(took).toBeLessThan(BUDGET_MS); |
new above
offline
Also pass closing to /reminders/today (and the Review all link in clock.ts) so today's reminders narrow as the issue asks.