Public: anyone can read this; the crew can change it.
small: Print a deal page on one sheet of paper for the client meeting #289
Check · Review · Land · Live — Pushed since the last review.
mason/deal-clock:mason/278-small-print-a-deal-page-on-one-sheet-of → main 9 commits fork 3 threads · 3 unresolved
8 files
changed +164
−17
src/clockprint.test.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | /** The clock on paper: title, timeline and this week; no navigation, buttons or forms. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { join } from "node:path"; | |
| 4 | import { clock } from "./pages/clock.ts"; | |
| 5 | ||
| 6 | test("the clock page marks itself for print and carries no inline script", () => { | |
| 7 | const html = clock({ who: { role: "user", id: "u", name: "Dana" }, on: "2026-10-10", deals: [] }); | |
| 8 | expect(html).toContain('<body class="clock-page">'); | |
| 9 | expect(html).not.toContain("<script>"); | |
| 10 | }); | |
| 11 | ||
| 12 | test("the clock's print rules hide the nav, buttons and forms and are scoped to it", async () => { | |
| 13 | const css = await Bun.file(join(import.meta.dir, "..", "static", "app.css")).text(); | |
| 14 | const media = css.slice(css.indexOf("/* The clock on paper")); | |
| 15 | expect(media).toContain("@media print"); | |
| 16 | expect(media).toMatch(/\.clock-page \.head[\s\S]*main button[\s\S]*main form[\s\S]*display: none/); | |
| 17 | const selectors = [...media.matchAll(/^\s*([^{}@\n]+)\{/gm)].map((m) => m[1]!.trim()); | |
| 18 | for (const sel of selectors) for (const part of sel.split(",")) expect(part.trim()).toStartWith(".clock-page"); | |
| 19 | }); |
src/dealprint.test.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | /** The deal page on paper: one sheet of address, parties and dates; no navigation, buttons or forms. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { join } from "node:path"; | |
| 4 | import { dealPage } from "./pages/deal.ts"; | |
| 5 | import type { DealView } from "./deals.ts"; | |
| 6 | ||
| 7 | const deal = { id: 1, address: "A", closed: false, note: "", contract_date: "2026-10-01", closing: "2026-11-01", closing_time: null, closing_place: null, business_days: 0, parties: [], deadlines: [], conflicts: [], closingConflicts: [], sent: [] } as unknown as DealView; | |
| 8 | ||
| 9 | test("the deal page marks itself and the two blocks that print", () => { | |
| 10 | const html = dealPage({ who: { role: "user", id: "u", name: "Dana" }, on: "2026-10-10", deal }); | |
| 11 | expect(html).toContain('<body class="deal-page">'); | |
| 12 | expect(html.match(/class="card print-keep/g)?.length).toBe(2); | |
| 13 | expect(html).not.toContain("<script>"); | |
| 14 | }); | |
| 15 | ||
| 16 | test("the print rules hide the nav, buttons and forms and are scoped to the deal page", async () => { | |
| 17 | const css = await Bun.file(join(import.meta.dir, "..", "static", "app.css")).text(); | |
| 18 | const from = css.indexOf("/* The deal page on one sheet"); | |
| 19 | const media = css.slice(from, css.indexOf("/* The clock on paper")); | |
| 20 | expect(media).toContain("@media print"); | |
| 21 | expect(media).toMatch(/\.deal-page \.head[\s\S]*main button[\s\S]*main form[\s\S]*display: none/); | |
| 22 | const selectors = [...media.matchAll(/^\s*([^{}@\n]+)\{/gm)].map((m) => m[1]!.trim()); | |
| 23 | for (const sel of selectors) for (const part of sel.split(",")) expect(part.trim()).toStartWith(".deal-page"); | |
| 24 | }); |
src/file.test.ts +1 −1
| @@ -81,7 +81,7 @@ test("a fell-through deal shows its reason", () => { | ||
| 81 | 81 | test("the print rules hide navigation and stay scoped to the file page", async () => { |
| 82 | 82 | const css = await Bun.file(join(import.meta.dir, "..", "static", "app.css")).text(); |
| 83 | 83 | const print = css.slice(css.indexOf("/* The broker file")); |
| 84 | const media = print.slice(print.indexOf("@media print")); | |
| 84 | const media = print.slice(print.indexOf("@media print"), print.indexOf("/* The deal page on one sheet")); | |
| 85 | 85 | expect(media).toMatch(/\.file-page \.head[\s\S]*display: none/); |
| 86 | 86 | const selectors = [...media.matchAll(/^\s*([^{}@\n]+)\{/gm)].map((m) => m[1]!.trim()); |
| 87 | 87 | for (const sel of selectors) for (const part of sel.split(",")) expect(part.trim()).toStartWith(".file-page"); |
src/pages/clock.ts +1 −1
| @@ -286,5 +286,5 @@ function render(input: { | ||
| 286 | 286 | } |
| 287 | 287 | <p class="muted small">Each date reminds its parties a few days before (three unless you set another, per date) and the morning of, and the one who owes a date is asked for an update the morning after it slips. Send records your approval and opens your own Mail or Messages with the words.</p> |
| 288 | 288 | </section>`}`; |
| 289 | return layout({ body, who, agent, waiting: input.waiting, tour: input.tour, path: "/", notice, open: opened?.hrefs }); | |
| 289 | return layout({ body, who, agent, waiting: input.waiting, tour: input.tour, path: "/", notice, open: opened?.hrefs, bodyClass: "clock-page" }); | |
| 290 | 290 | } |
src/pages/deal.ts +3 −3
| @@ -398,13 +398,13 @@ export function dealPage(input: { | ||
| 398 | 398 | </section> |
| 399 | 399 | <div id="deal" data-live="deal.changed"> |
| 400 | 400 | <p class="progress">${progressLine(deal, on)}</p> |
| 401 | <section class="card"> | |
| 401 | <section class="card print-keep"> | |
| 402 | 402 | <h2>Dates</h2> |
| 403 | 403 | ${conflictLines(deal)} |
| 404 | 404 | <ol class="dates">${dates}</ol> |
| 405 | 405 | </section> |
| 406 | 406 | ${comingCard(deal, agent ?? defaults, on)} |
| 407 | <section class="card" id="parties"> | |
| 407 | <section class="card print-keep" id="parties"> | |
| 408 | 408 | <h2>Parties</h2> |
| 409 | 409 | ${parties} |
| 410 | 410 | </section> |
| @@ -430,7 +430,7 @@ export function dealPage(input: { | ||
| 430 | 430 | }) |
| 431 | 431 | : "" |
| 432 | 432 | }`; |
| 433 | return layout({ body, who, agent, sheets: false, waiting: input.waiting, tour: input.tour, path, heading: deal.address, notice, open: opened?.hrefs }); | |
| 433 | return layout({ body, who, agent, sheets: false, waiting: input.waiting, tour: input.tour, path, heading: deal.address, notice, open: opened?.hrefs, bodyClass: "deal-page" }); | |
| 434 | 434 | } |
| 435 | 435 | |
| 436 | 436 | /** What the form was last sent with, what was wrong with it, and the dates it would make. */ |
src/sample.test.ts +3 −1
| @@ -257,11 +257,13 @@ test("on a fresh boot the booted clock page gives the year of last year's closin | ||
| 257 | 257 | }); |
| 258 | 258 | |
| 259 | 259 | test("on a fresh boot the first reminder for today is for a date already late or due today", () => { |
| 260 | // Pinned to an ordinary Wednesday: on a holiday the sample's due-today date steps forward, so the real day would make this flaky. | |
| 261 | const day = "2026-10-14"; | |
| 260 | 262 | const db = openDb(mkdtempSync(join(tmpdir(), "app-sample-first-"))); |
| 263 | restore(db, day); | |
| 261 | 264 | const alice: Who = { role: "user", id: "u1", name: "alice" }; |
| 262 | 265 | const listed = listDeals(db, alice); |
| 263 | 266 | if (!listed.ok) throw new Error(listed.message); |
| 264 | const day = today(DEFAULT_ZONE); | |
| 265 | 267 | const first = todaysDrafts(listed.value, day, agent)[0]!; |
outdated
Weakened invariant ( |
||
| 266 | 268 | expect(first).toBeDefined(); |
| 267 | 269 | expect(first.moment === "late" || first.due <= day).toBe(true); |
src/terminal.test.ts +26 −11
| @@ -3,17 +3,21 @@ | ||
| 3 | 3 | * database, does what the page's button does, through the same action. |
| 4 | 4 | */ |
| 5 | 5 | import { agentOf } from "./profile.ts"; |
| 6 | import { expect, test } from "bun:test"; | |
| 6 | import { expect, setDefaultTimeout, test } from "bun:test"; | |
| 7 | 7 | import { mkdtempSync } from "node:fs"; |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { addDays, spoken, today, DEFAULT_ZONE } from "./dates.ts"; |
| 11 | 11 | import { canChange, setAppointment, shown, addDeadline, addParty, createDeal, editParty, getDeal, listDeals, markDone, moveDeadline, sendReminder, updateDeal, type DealView } from "./deals.ts"; |
| 12 | 12 | import { openDb } from "./db.ts"; |
| 13 | import { clearSamples } from "./sample.ts"; | |
| 13 | 14 | import { shiftPlan } from "./shift.ts"; |
| 14 | 15 | import { terminal, type Terminal } from "./terminal.ts"; |
| 15 | 16 | import type { Who } from "./who.ts"; |
| 16 | 17 | |
| 18 | // Every key redraws the whole screen over a book that grows as the file goes on: several tests here take seconds, and the default five is too tight on a busy machine. | |
| 19 | setDefaultTimeout(30_000); | |
| 20 | ||
| 17 | 21 | const db = openDb(mkdtempSync(join(tmpdir(), "app-terminal-"))); |
| 18 | 22 | const alice: Who = { role: "user", id: "u1", name: "alice" }; |
| 19 | 23 | const context = { title: "deal-clock", where: "test", agent: { name: "Dana", brokerage: "HP", phone: "555", email: "[email protected]" } }; |
| @@ -247,28 +251,39 @@ test("e edits the party under the cursor; X removes a party or a custom date aft | ||
| 247 | 251 | }); |
| 248 | 252 | |
| 249 | 253 | test("n declines a reminder with a reason, u undoes it; anon is not asked to type", () => { |
| 250 | const t = terminal(db, alice, context); | |
| 254 | // The day is pinned to an ordinary Wednesday, so the first reminder is always one for that very day, however the real calendar falls. | |
| 255 | const pinned = "2026-10-14"; | |
| 256 | const own = openDb(mkdtempSync(join(tmpdir(), "app-terminal-decline-"))); | |
| 257 | clearSamples(own); | |
| 258 | // One deal whose second reminder (the day-of one) is dated the pinned day itself, so declining it leaves it listed to be undone. | |
| 259 | const elm = createDeal(own, alice, { openAnyway: true, address: "12 Elm St", buyer: "Ann Buyer", seller: "Sam Seller", contractDate: addDays(pinned, -3), closingDate: addDays(pinned, 42) }); | |
| 260 | if (!elm.ok) throw new Error(elm.message); | |
| 261 | const there = { ...context, on: () => pinned }; | |
| 262 | const t = terminal(own, alice, there); | |
| 251 | 263 | t.press("r"); |
| 264 | t.press("j"); | |
| 252 | 265 | const row = under(t); |
outdated
This |
||
| 253 | const before = db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n; | |
| 266 | const declined = () => own.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n; | |
| 267 | const before = declined(); | |
| 254 | 268 | press(t, "n", "Buyer confirmed by phone", "\r"); |
| 255 | 269 | expect(lines(t).join("\n")).toContain("Not sent to"); |
| 256 | expect(db.query<{ reason: string }, []>("SELECT reason FROM reminder_declined ORDER BY id DESC").get()!.reason).toBe("Buyer confirmed by phone"); | |
| 257 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n).toBe(before + 1); | |
| 270 | expect(own.query<{ reason: string }, []>("SELECT reason FROM reminder_declined ORDER BY id DESC").get()!.reason).toBe("Buyer confirmed by phone"); | |
| 271 | expect(declined()).toBe(before + 1); | |
| 258 | 272 | expect(under(t)).not.toBe(row); |
| 273 | expect(under(t)).toContain("not sent"); | |
| 259 | 274 | // An empty reason is refused with the action's words. |
| 260 | press(t, "j", "n", "\r"); | |
| 275 | press(t, "k", "n", "\r"); | |
| 261 | 276 | expect(lines(t).join("\n")).toContain("Say why"); |
| 262 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n).toBe(before + 1); | |
| 263 | t.press("k"); | |
| 277 | expect(declined()).toBe(before + 1); | |
| 278 | t.press("j"); | |
| 264 | 279 | expect(under(t)).toContain("not sent"); |
| 265 | 280 | t.press("u"); |
| 266 | 281 | expect(lines(t).join("\n")).toContain("is a draft again"); |
| 267 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n).toBe(before); | |
| 268 | const guest = terminal(db, { role: "anon" }, context); | |
| 282 | expect(declined()).toBe(before); | |
| 283 | const guest = terminal(own, { role: "anon" }, there); | |
| 269 | 284 | guest.press("r"); |
| 270 | 285 | press(guest, "n", "x", "\r"); |
| 271 | expect(db.query<{ n: number }, []>("SELECT count(*) AS n FROM reminder_declined").get()!.n).toBe(before); | |
| 286 | expect(declined()).toBe(before); | |
| 272 | 287 | }); |
| 273 | 288 | |
| 274 | 289 | test("h holds a reminder until tomorrow: it leaves the list, is written, and anon cannot", () => { |
static/app.css +87
| @@ -1167,3 +1167,90 @@ a.marker { | ||
| 1167 | 1167 | font-size: 1rem; |
| 1168 | 1168 | } |
| 1169 | 1169 | } |
| 1170 | ||
| 1171 | /* The deal page on one sheet: address, parties and the dated list, black on white; no tools. */ | |
| 1172 | @media print { | |
| 1173 | .deal-page { | |
| 1174 | background: white; | |
| 1175 | color: black; | |
| 1176 | font-size: 10pt; | |
| 1177 | } | |
| 1178 | .deal-page .head, | |
| 1179 | .deal-page footer, | |
| 1180 | .deal-page .toast, | |
| 1181 | .deal-page .crumb, | |
| 1182 | .deal-page .intro .row, | |
| 1183 | .deal-page .intro details, | |
| 1184 | .deal-page .intro form, | |
| 1185 | .deal-page .progress, | |
| 1186 | .deal-page main > :not(.intro):not(#deal), | |
| 1187 | .deal-page #deal > :not(.print-keep), | |
| 1188 | .deal-page main button, | |
| 1189 | .deal-page main form, | |
| 1190 | .deal-page main details, | |
| 1191 | .deal-page main [popover], | |
| 1192 | .deal-page .party-controls { | |
| 1193 | display: none; | |
| 1194 | } | |
| 1195 | .deal-page .page { | |
| 1196 | max-width: none; | |
| 1197 | padding: 0; | |
| 1198 | } | |
| 1199 | .deal-page .card { | |
| 1200 | border: 0; | |
| 1201 | box-shadow: none; | |
| 1202 | padding: 0; | |
| 1203 | background: white; | |
| 1204 | } | |
| 1205 | .deal-page .date-row, | |
| 1206 | .deal-page .party-row { | |
| 1207 | break-inside: avoid; | |
| 1208 | color: black; | |
| 1209 | } | |
| 1210 | .deal-page h2 { | |
| 1211 | break-after: avoid; | |
| 1212 | font-size: 1rem; | |
| 1213 | } | |
| 1214 | .deal-page .muted { | |
| 1215 | color: black; | |
| 1216 | } | |
| 1217 | } | |
| 1218 | ||
| 1219 | /* The clock on paper: the title, the timeline and this week, black on white; no tools. */ | |
| 1220 | @media print { | |
| 1221 | .clock-page { | |
| 1222 | background: white; | |
| 1223 | color: black; | |
| 1224 | font-size: 10pt; | |
| 1225 | } | |
| 1226 | .clock-page .head, | |
| 1227 | .clock-page footer, | |
| 1228 | .clock-page .toast, | |
| 1229 | .clock-page .intro .actions, | |
| 1230 | .clock-page .find, | |
| 1231 | .clock-page .legend, | |
| 1232 | .clock-page main > :not(.intro):not(#clock):not(#week), | |
| 1233 | .clock-page main button, | |
| 1234 | .clock-page main form, | |
| 1235 | .clock-page main details, | |
| 1236 | .clock-page main [popover] { | |
| 1237 | display: none; | |
| 1238 | } | |
| 1239 | .clock-page .page { | |
| 1240 | max-width: none; | |
| 1241 | padding: 0; | |
| 1242 | } | |
| 1243 | .clock-page .card { | |
| 1244 | border: 0; | |
| 1245 | box-shadow: none; | |
| 1246 | padding: 0; | |
| 1247 | background: white; | |
| 1248 | } | |
| 1249 | .clock-page .muted { | |
| 1250 | color: black; | |
| 1251 | } | |
| 1252 | .clock-page h2 { | |
| 1253 | break-after: avoid; | |
| 1254 | font-size: 1rem; | |
| 1255 | } | |
| 1256 | } |
as neverhides the input shape from the type checker; use a typed fixture.