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

opened this

Check · Review · Land · Live — Pushed since the last review. Next: reviewer, pushed since the last review.

Ready to land

mason/deal-clock:mason/278-small-print-a-deal-page-on-one-sheet-of → main 9 commits fork 3 threads · 3 unresolved

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

    Check passeddeploy 6327589

    Website spec: 3 findings, 3 the edge owns (report only)
    • / no <link rel="canonical"> — canonical
    • / missing og:image — open-graph
    • / http://127.0.0.1:44689/tokens.css?v=2d14bc8d: a 2410-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / http://127.0.0.1:44689/static/app.css?v=900b80de: a 25293-byte body was sent without br or gzip though Accept-Encoding: br, gzip — https://specification.website/ (the edge owns this)
    • / no Cross-Origin-Opener-Policy — coop (the edge owns this)
    • / no ETag on the HTML — caching
    You need write access to merge.

Review

Sign in to review this change.
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: [] });
outdated

as never hides the input shape from the type checker; use a typed fixture.

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", () => {
8181 test("the print rules hide navigation and stay scoped to the file page", async () => {
8282 const css = await Bun.file(join(import.meta.dir, "..", "static", "app.css")).text();
8383 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"));
8585 expect(media).toMatch(/\.file-page \.head[\s\S]*display: none/);
8686 const selectors = [...media.matchAll(/^\s*([^{}@\n]+)\{/gm)].map((m) => m[1]!.trim());
8787 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: {
286286 }
287287 <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>
288288 </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" });
290290 }
src/pages/deal.ts +3 −3
@@ -398,13 +398,13 @@ export function dealPage(input: {
398398 </section>
399399 <div id="deal" data-live="deal.changed">
400400 <p class="progress">${progressLine(deal, on)}</p>
401 <section class="card">
401 <section class="card print-keep">
402402 <h2>Dates</h2>
403403 ${conflictLines(deal)}
404404 <ol class="dates">${dates}</ol>
405405 </section>
406406 ${comingCard(deal, agent ?? defaults, on)}
407 <section class="card" id="parties">
407 <section class="card print-keep" id="parties">
408408 <h2>Parties</h2>
409409 ${parties}
410410 </section>
@@ -430,7 +430,7 @@ export function dealPage(input: {
430430 })
431431 : ""
432432 }`;
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" });
434434 }
435435
436436 /** 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
257257 });
258258
259259 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";
260262 const db = openDb(mkdtempSync(join(tmpdir(), "app-sample-first-")));
263 restore(db, day);
261264 const alice: Who = { role: "user", id: "u1", name: "alice" };
262265 const listed = listDeals(db, alice);
263266 if (!listed.ok) throw new Error(listed.message);
264 const day = today(DEFAULT_ZONE);
265267 const first = todaysDrafts(listed.value, day, agent)[0]!;
outdated

Weakened invariant (<= nextBusinessDay(day)). Pin the boot day to a business day and keep first.due <= day.

266268 expect(first).toBeDefined();
267269 expect(first.moment === "late" || first.due <= day).toBe(true);
src/terminal.test.ts +26 −11
@@ -3,17 +3,21 @@
33 * database, does what the page's button does, through the same action.
44 */
55 import { agentOf } from "./profile.ts";
6 import { expect, test } from "bun:test";
6 import { expect, setDefaultTimeout, test } from "bun:test";
77 import { mkdtempSync } from "node:fs";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { addDays, spoken, today, DEFAULT_ZONE } from "./dates.ts";
1111 import { canChange, setAppointment, shown, addDeadline, addParty, createDeal, editParty, getDeal, listDeals, markDone, moveDeadline, sendReminder, updateDeal, type DealView } from "./deals.ts";
1212 import { openDb } from "./db.ts";
13 import { clearSamples } from "./sample.ts";
1314 import { shiftPlan } from "./shift.ts";
1415 import { terminal, type Terminal } from "./terminal.ts";
1516 import type { Who } from "./who.ts";
1617
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
1721 const db = openDb(mkdtempSync(join(tmpdir(), "app-terminal-")));
1822 const alice: Who = { role: "user", id: "u1", name: "alice" };
1923 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
247251 });
248252
249253 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);
251263 t.press("r");
264 t.press("j");
252265 const row = under(t);
outdated

This if makes the undo path optional: on a holiday nothing is asserted. Pin the day so the draft is always listed and assert unconditionally.

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();
254268 press(t, "n", "Buyer confirmed by phone", "\r");
255269 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);
258272 expect(under(t)).not.toBe(row);
273 expect(under(t)).toContain("not sent");
259274 // An empty reason is refused with the action's words.
260 press(t, "j", "n", "\r");
275 press(t, "k", "n", "\r");
261276 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");
264279 expect(under(t)).toContain("not sent");
265280 t.press("u");
266281 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);
269284 guest.press("r");
270285 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);
272287 });
273288
274289 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 {
11671167 font-size: 1rem;
11681168 }
11691169 }
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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs