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 #311
mason/deal-clock:mason/309-the-agent-can-narrow-the-clock-to-deals → main 7 commits fork 2 threads · 2 unresolved
32 files
changed +224
−27
.bash_profile added
No content changes.
.bashrc added
No content changes.
.claude/agents added
No content changes.
.claude/commands added
No content changes.
.claude/hooks added
No content changes.
.claude/launch.json added
No content changes.
.claude/loop.md added
No content changes.
.claude/output-styles added
No content changes.
.claude/routines added
No content changes.
.claude/scheduled_tasks.json added
No content changes.
.claude/settings.json added
No content changes.
.claude/settings.local.json added
No content changes.
.claude/skills added
No content changes.
.claude/workflows added
No content changes.
.gitconfig added
No content changes.
.gitmodules added
No content changes.
.idea added
No content changes.
.mcp.json added
No content changes.
.profile added
No content changes.
.ripgreprc added
No content changes.
.vscode added
No content changes.
.zprofile added
No content changes.
.zshrc added
No content changes.
README.md +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; 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 +117
| @@ -0,0 +1,117 @@ | ||
| 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(); | |
outdated
Replace the |
||
| 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("every bucket over the sample deals, dated from today, partitions them", () => { | |
| 82 | const today = new Date().toISOString().slice(0, 10); | |
| 83 | restore(db, today); | |
| 84 | const deals = mine().filter((d) => !d.closed && !d.fell_through); | |
| 85 | expect(deals.length).toBeGreaterThan(2); | |
| 86 | const ids = (w: Parameters<typeof closingIn>[2]) => closingIn(deals, today, w).map((d) => d.id).sort(); | |
| 87 | const week = ids("week"); | |
| 88 | const later = ids("later"); | |
| 89 | const month = ids("month"); | |
| 90 | const rest = month.filter((id) => !week.includes(id)); | |
| 91 | expect([...week, ...rest, ...later].sort()).toEqual(ids("")); | |
| 92 | for (const d of closingIn(deals, today, "week")) expect(d.closing <= addDays(today, 6)).toBe(true); | |
| 93 | for (const d of closingIn(deals, today, "later")) expect(d.closing > addDays(today, 6)).toBe(true); | |
| 94 | for (const id of ids("month")) expect(ids("")).toContain(id); | |
| 95 | expect(week.every((id) => ids("month").includes(id))).toBe(true); | |
| 96 | }); | |
| 97 | ||
| 98 | test("composes with find on real deals", () => { | |
| 99 | const today = new Date().toISOString().slice(0, 10); | |
| 100 | restore(db, today); | |
| 101 | const deals = mine(); | |
| 102 | const harbor = find(deals, "harbor"); | |
| 103 | expect(harbor.length).toBeGreaterThan(0); | |
| 104 | const inMonth = closingIn(harbor, today, "month").length; | |
| 105 | expect(inMonth + closingIn(harbor, today, "later").length).toBe(harbor.length); | |
| 106 | expect(closingIn(find(deals, "zzz-nothing"), today, "week")).toEqual([]); | |
| 107 | }); | |
| 108 | ||
| 109 | test("the empty message names what narrowed", async () => { | |
| 110 | const today = new Date().toISOString().slice(0, 10); | |
| 111 | db.query("DELETE FROM deal").run(); | |
| 112 | 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 }); | |
| 113 | if (!made.ok) throw new Error(made.message); | |
| 114 | expect(await (await get("/?closing=week")).text()).toContain("No deal matches that closing."); | |
| 115 | expect(await (await get("/?closing=week&side=buyer")).text()).toContain("No deal matches those filters."); | |
| 116 | expect(await (await get("/?closing=week&q=zzz")).text()).toContain("No deal matches “zzz”."); | |
| 117 | }); |
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 +17 −11
| @@ -18,8 +18,10 @@ 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"; | |
| 23 | 25 | import { dealSideLabel, sideFilterSelect, sideLabel } from "./side.ts"; |
| 24 | 26 | import { memoizer, print } from "../memo.ts"; |
| 25 | 27 | import { bookLine, tally } from "../book.ts"; |
| @@ -102,15 +104,15 @@ export function axis(start: string, days: number, on: string): string { | ||
| 102 | 104 | } |
| 103 | 105 | |
| 104 | 106 | /** 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 !== ""); | |
| 107 | const weekQuery = (q: string, side: SideFilter, closing: ClosingFilter): string => { | |
| 108 | const parts = [q === "" ? "" : `q=${encodeURIComponent(q)}`, side === "" ? "" : `side=${side}`, closing === "" ? "" : `closing=${closing}`].filter((p) => p !== ""); | |
| 107 | 109 | return parts.length === 0 ? "" : `?${parts.join("&")}`; |
| 108 | 110 | }; |
| 109 | 111 | |
| 110 | 112 | const weekRows = memoizer<string>(3000); |
| 111 | 113 | |
| 112 | 114 | /** 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 { | |
| 115 | export function weekCard(deals: readonly DealView[], on: string, q = "", side: SideFilter = "", closing: ClosingFilter = ""): string { | |
| 114 | 116 | const groups = byDay(thisWeek(deals, on), on); |
| 115 | 117 | const body = |
| 116 | 118 | groups.length === 0 |
| @@ -127,7 +129,7 @@ export function weekCard(deals: readonly DealView[], on: string, q = "", side: S | ||
| 127 | 129 | .join(""); |
| 128 | 130 | return `<section id="week" class="card" data-live="deal.changed"> |
| 129 | 131 | <h2>This week</h2> |
| 130 | <p class="small"><a href="/week${weekQuery(q, side)}">Print this week</a></p> | |
| 132 | <p class="small"><a href="/week${weekQuery(q, side, closing)}">Print this week</a></p> | |
| 131 | 133 | ${body} |
| 132 | 134 | </section>`; |
| 133 | 135 | } |
| @@ -162,9 +164,10 @@ export function clock(input: { | ||
| 162 | 164 | open?: number | undefined; |
| 163 | 165 | q?: string | undefined; |
| 164 | 166 | side?: SideFilter | undefined; |
| 167 | closing?: ClosingFilter | undefined; | |
| 165 | 168 | }): string { |
| 166 | 169 | 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]) + | |
| 170 | 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 | 171 | input.deals.map(print).join(), |
| 169 | 172 | ); |
| 170 | 173 | return pages(`${input.deals.length}:${key}`, () => render(input)); |
| @@ -183,6 +186,7 @@ function render(input: { | ||
| 183 | 186 | open?: number | undefined; |
| 184 | 187 | q?: string | undefined; |
| 185 | 188 | side?: SideFilter | undefined; |
| 189 | closing?: ClosingFilter | undefined; | |
| 186 | 190 | }): string { |
| 187 | 191 | const { who, on, notice } = input; |
| 188 | 192 | const agent = input.agent ?? defaults; |
| @@ -190,7 +194,8 @@ function render(input: { | ||
| 190 | 194 | input.open === undefined ? undefined : input.deals.flatMap((d) => d.sent).find((m) => m.id === input.open); |
| 191 | 195 | const q = input.q ?? ""; |
| 192 | 196 | const side = input.side ?? ""; |
| 193 | const found = onSide(find(input.deals, q), side); | |
| 197 | const closing = input.closing ?? ""; | |
| 198 | const found = closingIn(onSide(find(input.deals, q), side), on, closing); | |
| 194 | 199 | const deals = found.filter((deal) => !deal.closed && !deal.fell_through); |
| 195 | 200 | const closed = found |
| 196 | 201 | .filter((deal) => deal.closed && !deal.fell_through) |
| @@ -205,11 +210,11 @@ function render(input: { | ||
| 205 | 210 | // A closed deal is not on the clock, but its check-in is a reminder for today. |
| 206 | 211 | const today = [...deals, ...closed].map((deal) => reminderRows(deal, on, agent)); |
| 207 | 212 | const todayCount = today.reduce((n, t) => n + t.count, 0); |
| 208 | const none = (q !== "" || side !== "") && found.length === 0; | |
| 213 | const none = (q !== "" || side !== "" || closing !== "") && found.length === 0; | |
| 209 | 214 | const toSend = today.reduce((n, t) => n + t.toSend, 0); |
| 210 | 215 | const nothing = |
| 211 | q !== "" || side !== "" | |
| 212 | ? `<p class="empty">No deal matches${q === "" ? " that side" : ` “${escape(q)}”`}. <a href="/">Clear</a></p>` | |
| 216 | q !== "" || side !== "" || closing !== "" | |
| 217 | ? `<p class="empty">No deal matches${q !== "" ? ` “${escape(q)}”` : closing !== "" && side !== "" ? " those filters" : closing !== "" ? " that closing" : " that side"}. <a href="/">Clear</a></p>` | |
| 213 | 218 | : `<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 | 219 | const timeline = |
| 215 | 220 | deals.length === 0 && closed.length === 0 && fell.length === 0 |
| @@ -254,7 +259,8 @@ function render(input: { | ||
| 254 | 259 | <label for="find-q">Find a deal</label> |
| 255 | 260 | <input id="find-q" class="input" type="search" name="q" value="${escape(q)}" maxlength="100" placeholder="Address, client or party" autocomplete="off"> |
| 256 | 261 | ${sideFilterSelect(side)} |
| 257 | <button class="button button-quiet">Find</button>${q === "" && side === "" ? "" : ` <a href="/">Clear</a>`} | |
| 262 | ${closingFilterSelect(closing)} | |
| 263 | <button class="button button-quiet">Find</button>${q === "" && side === "" && closing === "" ? "" : ` <a href="/">Clear</a>`} | |
| 258 | 264 | </form> |
| 259 | 265 | <section id="clock" class="card clock" data-live="deal.changed"> |
| 260 | 266 | ${timeline} |
| @@ -268,7 +274,7 @@ function render(input: { | ||
| 268 | 274 | </section> |
| 269 | 275 | ${closedCard} |
| 270 | 276 | ${restoreCard} |
| 271 | ${deals.length === 0 ? "" : weekCard(deals, on, q, side)} | |
| 277 | ${deals.length === 0 ? "" : weekCard(deals, on, q, side, closing)} | |
| 272 | 278 | ${none ? "" : `<section id="today" class="card" data-live="deal.changed"> |
| 273 | 279 | <h2>Reminders for today <span class="muted">· ${toSend === 0 ? "all sent" : `${toSend} to send`}</span></h2> |
| 274 | 280 | ${toSend === 0 ? "" : `<p><a class="button" href="/reminders/today${q === "" ? "" : `?q=${encodeURIComponent(q)}`}">Review all</a></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/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 +10 −2
| @@ -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, 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"; |
| @@ -816,7 +817,10 @@ export const routes: readonly Route[] = [ | ||
| 816 | 817 | const side = sideFilter(c.url.searchParams.get("side")); |
| 817 | 818 | if (side === undefined) |
| 818 | 819 | 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); | |
| 820 | const closing = closingFilter(c.url.searchParams.get("closing")); | |
| 821 | if (closing === undefined) | |
| 822 | return html(refusedPage({ who: c.who, agent: agentFor(c), path: "/", heading: "The clock", error: "invalid", message: CLOSING_REFUSAL }), 422, clear); | |
| 823 | 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 | 824 | }, |
| 821 | 825 | }, |
| 822 | 826 | { |
| @@ -838,7 +842,11 @@ export const routes: readonly Route[] = [ | ||
| 838 | 842 | const side = sideFilter(c.url.searchParams.get("side")); |
| 839 | 843 | if (side === undefined) |
| 840 | 844 | 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 })); | |
| 845 | const closing = closingFilter(c.url.searchParams.get("closing")); | |
| 846 | if (closing === undefined) | |
| 847 | return html(refusedPage({ who: c.who, agent: agentFor(c), path: "/week", heading: "This week", error: "invalid", message: CLOSING_REFUSAL }), 422); | |
| 848 | const on = dayOf(agentFor(c)); | |
| 849 | return html(weekPage({ who: c.who, agent: agentFor(c), deals: closingIn(onSide(find(deals.value, q), side), on, closing), on, q, side, closing })); | |
| 842 | 850 | }, |
| 843 | 851 | }, |
| 844 | 852 | { |
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
Re-wrap: this line runs past the paragraph's width.