Public: anyone can read this; the crew can change it.
Fix a Fair Housing flag in one tap with the suggested wording #20
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/9-fix-a-fair-housing-flag-in-one-tap-with → main 2 commits fork 2 threads · 2 unresolved
14 files
changed +337
−12
README.md +11
| @@ -50,6 +50,17 @@ list cannot drift from the app. | ||
| 50 | 50 | clear, amber with the risky words underlined and a one-line reason — |
| 51 | 51 | *"'family-friendly' implies familial status; say 'near parks and |
| 52 | 52 | schools'"*. The check runs again as you type and on every save. |
| 53 | Where a finding's rewrite is a phrase that can stand in its place, the | |
| 54 | aside has a **Use "near parks and schools"** button beside it (only for | |
| 55 | whoever may edit the listing; advice such as "describe the home, not the | |
| 56 | faith of who might buy it" gets none). It replaces exactly those words — | |
| 57 | checked against the stored text by start and end, so a stale page answers | |
| 58 | 409 and changes nothing — and saves as any edit does: checked again, | |
| 59 | audited, approval cleared. It works as a plain form POST | |
| 60 | (`/listings/:id/assets/:kind/suggestion`) and updates the box in place | |
| 61 | with JavaScript; the `use_suggestion` MCP tool takes `{ id, kind, start, | |
| 62 | end, text }`, and `u` in the terminal uses the first suggestion on the | |
| 63 | asset under the cursor. | |
| 53 | 64 | Each asset also has an **Approve** button ("Approve with 2 flags" when |
| 54 | 65 | the check still has findings): it records who signed off and when, the |
| 55 | 66 | header counts "4 of 6 approved" and turns to "Ready to send" at 6 of 6, |
src/actions.ts +45
| @@ -594,6 +594,51 @@ function edit( | ||
| 594 | 594 | return ok(launched(db, found.value.id)!); |
| 595 | 595 | } |
| 596 | 596 | |
| 597 | /** | |
| 598 | * Take a Fair Housing finding's suggested wording in one tap: the words at | |
| 599 | * `start`..`end` become the rule's drop-in phrase and the result is saved | |
| 600 | * as any edit is (checked again, audited, approval cleared). `text` is what | |
| 601 | * the person saw flagged there; if the stored words no longer have that | |
| 602 | * finding at that place, nothing changes (`conflict`). | |
| 603 | */ | |
| 604 | export const useSuggestion = ( | |
| 605 | db: Database, | |
| 606 | who: Who, | |
| 607 | input: { id: number; kind: string; start: unknown; end: unknown; text: unknown }, | |
| 608 | ): Result<Launched> => | |
| 609 | audited(db, who, "listing", "update", input.id, suggest(db, who, input)); | |
| 610 | ||
| 611 | function suggest( | |
| 612 | db: Database, | |
| 613 | who: Who, | |
| 614 | input: { id: number; kind: string; start: unknown; end: unknown; text: unknown }, | |
| 615 | ): Result<Launched> { | |
| 616 | const found = listingFor(db, who, "update", input.id, { | |
| 617 | signIn: "Sign in to edit this listing.", | |
| 618 | no: "Only the listing's agent or an admin can edit it.", | |
| 619 | }); | |
| 620 | if (!found.ok) return found; | |
| 621 | if (!isKind(input.kind)) | |
| 622 | return fail("invalid", `An asset is one of ${KINDS.join(", ")}.`); | |
| 623 | const { start, end } = input; | |
| 624 | if (!Number.isInteger(start) || !Number.isInteger(end) || typeof input.text !== "string") | |
| 625 | return fail("invalid", "Say which words: start and end are whole numbers, text is the flagged words."); | |
| 626 | const asset = found.value.assets.find((a) => a.kind === input.kind); | |
| 627 | if (asset === undefined) return fail("not_found", "No such asset."); | |
| 628 | const finding = asset.check.findings.find( | |
| 629 | (f) => f.start === start && f.end === end && f.text === input.text, | |
| 630 | ); | |
| 631 | if (finding === undefined) | |
| 632 | return fail("conflict", "These words changed since you read them. Read them again before taking the suggestion."); | |
| 633 | if (finding.dropIn === null) | |
| 634 | return fail("invalid", "That finding has advice, not a phrase to put in its place."); | |
| 635 | return edit(db, who, { | |
| 636 | id: found.value.id, | |
| 637 | kind: input.kind, | |
| 638 | text: asset.text.slice(0, finding.start) + finding.dropIn + asset.text.slice(finding.end), | |
| 639 | }); | |
| 640 | } | |
| 641 | ||
| 597 | 642 | /** |
| 598 | 643 | * A person signs off one asset's words (or takes it back): who and when go |
| 599 | 644 | * on the row, and any change to the words clears it. Whoever may update the |
src/contract.test.ts +41
| @@ -23,6 +23,7 @@ import { | ||
| 23 | 23 | listListings, |
| 24 | 24 | listNotes, |
| 25 | 25 | updateNote, |
| 26 | useSuggestion, | |
| 26 | 27 | } from "./actions.ts"; |
| 27 | 28 | import { record } from "./audit.ts"; |
| 28 | 29 | import { actingAs, identities, table, type Verb } from "./can.ts"; |
| @@ -170,3 +171,43 @@ test("approving an asset answers as the listing's update cell says, at the actio | ||
| 170 | 171 | } |
| 171 | 172 | expect(asked).toBeGreaterThan(0); |
| 172 | 173 | }); |
| 174 | ||
| 175 | /** Taking a suggestion is `listing:update`: the action and the MCP tool let through exactly whom the table's update cell does. */ | |
| 176 | test("taking a suggested wording answers as the listing's update cell says, at the action and the tool", () => { | |
| 177 | let asked = 0; | |
| 178 | for (const cell of table(contract)) { | |
| 179 | if (cell.resource !== "listing" || cell.verb !== "update") continue; | |
| 180 | const who = whoIs(cell.as); | |
| 181 | const { mine, theirs } = actingAs(contract, cell.as); | |
| 182 | const attempts = (authorId: string) => { | |
| 183 | const id = listingBy(authorId); | |
| 184 | const root = { role: "admin" as const, id: "root", name: "root" }; | |
| 185 | const flagged = (text: string) => { | |
| 186 | editAsset(db, root, { id, kind: "mls", text }); | |
| 187 | const l = getListing(db, root, { id }); | |
| 188 | return l.ok ? l.value.assets.find((a) => a.kind === "mls")!.check.findings[0]! : undefined; | |
| 189 | }; | |
| 190 | const ask = (f: NonNullable<ReturnType<typeof flagged>>) => ({ id, kind: "mls", start: f.start, end: f.end, text: f.text }); | |
| 191 | const first = flagged("A family-friendly street."); | |
| 192 | const direct = useSuggestion(db, who, ask(first!)).ok; | |
| 193 | const again = flagged("A family-friendly street."); | |
| 194 | const called = rpc(db, who, { | |
| 195 | jsonrpc: "2.0", | |
| 196 | id: 1, | |
| 197 | method: "tools/call", | |
| 198 | params: { name: "use_suggestion", arguments: ask(again!) }, | |
| 199 | }); | |
| 200 | return [direct, !JSON.stringify(called.body).includes('"isError":true')]; | |
| 201 | }; | |
| 202 | const onMine = attempts(String(mine["author_id"])); | |
| 203 | const onTheirs = attempts(String(theirs["author_id"])); | |
| 204 | const said = `use_suggestion as ${cell.as}`; | |
| 205 | asked++; | |
| 206 | if (cell.answer === "yes") | |
| 207 | expect([said, onMine, onTheirs]).toEqual([said, [true, true], [true, true]]); | |
| 208 | else if (cell.answer === "no") | |
| 209 | expect([said, onMine, onTheirs]).toEqual([said, [false, false], [false, false]]); | |
| 210 | else expect([said, onMine, onTheirs]).toEqual([said, [true, true], [false, false]]); | |
| 211 | } | |
| 212 | expect(asked).toBeGreaterThan(0); | |
| 213 | }); |
src/fairhousing.test.ts +7
| @@ -86,3 +86,10 @@ test("every finding is located, once, in order", () => { | ||
| 86 | 86 | expect(check("An exclusive, safe neighborhood.").findings.length).toBe(2); |
| 87 | 87 | expect(check("").clear).toBe(true); |
| 88 | 88 | }); |
| 89 | ||
| 90 | test("a finding carries a drop-in phrase only where the rule's rewrite can stand in its place", () => { | |
| 91 | expect(check("A family-friendly street.").findings[0]?.dropIn).toBe("near parks and schools"); | |
| 92 | expect(check("Bring the mother-in-law suite.").findings[0]?.dropIn).toBe("guest suite"); | |
| 93 | expect(check("A Christian home.").findings[0]?.dropIn).toBeNull(); | |
| 94 | expect(check("Perfect for a young family.").findings[0]?.dropIn).toBeNull(); | |
| 95 | }); |
src/fairhousing.ts +11
| @@ -30,6 +30,8 @@ export interface Rule { | ||
| 30 | 30 | readonly why: string; |
| 31 | 31 | /** What to say instead. */ |
| 32 | 32 | readonly say: string; |
| 33 | /** `say` when it is a phrase that can stand where the flagged words were; absent when it is advice. */ | |
| 34 | readonly dropIn?: string; | |
| 33 | 35 | /** The context that clears a match: the text and where the match sits. */ |
| 34 | 36 | readonly unless?: (text: string, start: number, end: number) => boolean; |
| 35 | 37 | } |
| @@ -44,6 +46,8 @@ export interface Finding { | ||
| 44 | 46 | /** One line: "'family-friendly' implies familial status; say 'near parks and schools'". */ |
| 45 | 47 | readonly line: string; |
| 46 | 48 | readonly say: string; |
| 49 | /** The words to put in place of `text` in one tap, or null when `say` is advice. */ | |
| 50 | readonly dropIn: string | null; | |
| 47 | 51 | } |
| 48 | 52 | |
| 49 | 53 | export interface Check { |
| @@ -80,6 +84,7 @@ export const RULES: readonly Rule[] = [ | ||
| 80 | 84 | level: "risk", |
| 81 | 85 | why: "implies familial status", |
| 82 | 86 | say: "near parks and schools", |
| 87 | dropIn: "near parks and schools", | |
| 83 | 88 | }, |
| 84 | 89 | { |
| 85 | 90 | pattern: rx("kid[- ]friendly|child[- ]friendly"), |
| @@ -87,6 +92,7 @@ export const RULES: readonly Rule[] = [ | ||
| 87 | 92 | level: "risk", |
| 88 | 93 | why: "implies familial status", |
| 89 | 94 | say: "near parks and schools", |
| 95 | dropIn: "near parks and schools", | |
| 90 | 96 | }, |
| 91 | 97 | { |
| 92 | 98 | pattern: rx( |
| @@ -103,6 +109,7 @@ export const RULES: readonly Rule[] = [ | ||
| 103 | 109 | level: "risk", |
| 104 | 110 | why: "implies familial status", |
| 105 | 111 | say: "quiet street near parks", |
| 112 | dropIn: "quiet street near parks", | |
| 106 | 113 | }, |
| 107 | 114 | { |
| 108 | 115 | pattern: rx( |
| @@ -128,6 +135,7 @@ export const RULES: readonly Rule[] = [ | ||
| 128 | 135 | level: "note", |
| 129 | 136 | why: "presumes who will live there", |
| 130 | 137 | say: "guest suite' or 'accessory dwelling unit", |
| 138 | dropIn: "guest suite", | |
| 131 | 139 | }, |
| 132 | 140 | |
| 133 | 141 | // Religion. |
| @@ -148,6 +156,7 @@ export const RULES: readonly Rule[] = [ | ||
| 148 | 156 | level: "risk", |
| 149 | 157 | why: "uses a place of worship to signal who belongs", |
| 150 | 158 | say: "near shops, parks and transit", |
| 159 | dropIn: "near shops, parks and transit", | |
| 151 | 160 | unless: streetName, |
| 152 | 161 | }, |
| 153 | 162 | { |
| @@ -249,6 +258,7 @@ export const RULES: readonly Rule[] = [ | ||
| 249 | 258 | level: "note", |
| 250 | 259 | why: "presumes the buyer's sex", |
| 251 | 260 | say: "den' or 'bonus room", |
| 261 | dropIn: "den", | |
| 252 | 262 | }, |
| 253 | 263 | { |
| 254 | 264 | pattern: rx("his (?:and|&) hers?"), |
| @@ -285,6 +295,7 @@ export function check(text: string, rules: readonly Rule[] = RULES): Check { | ||
| 285 | 295 | level: rule.level, |
| 286 | 296 | line: `'${m[0]}' ${rule.why}; say '${rule.say}'`, |
| 287 | 297 | say: rule.say.replace(/'/g, ""), |
| 298 | dropIn: rule.dropIn ?? null, | |
| 288 | 299 | }); |
| 289 | 300 | } |
| 290 | 301 | } |
src/listings.test.ts +71
| @@ -495,3 +495,74 @@ test("the home list says what is approved, and flyer and email wear a draft band | ||
| 495 | 495 | for (const a of listed.assets) if (a.kind !== "flyer" && a.kind !== "email") expect((await approve(a.kind)).status).toBe(200); |
| 496 | 496 | expect(await home()).toContain("Ready to send"); |
| 497 | 497 | }); |
| 498 | ||
| 499 | test("one tap takes the suggested wording: exactly those words change, and it saves like an edit", async () => { | |
| 500 | const db = openDb(dir); | |
| 501 | const carol = as("dana"); | |
| 502 | const { to } = await launch(carol); | |
| 503 | const id = Number(to.split("/").at(-1)); | |
| 504 | const text = "A family-friendly street. Bring your mother-in-law suite. Walk to church, a Christian home."; | |
| 505 | const put = new FormData(); | |
| 506 | put.set("text", text); | |
| 507 | await fetch(`${base}/listings/${id}/assets/social_short`, { method: "POST", body: put, headers: carol, redirect: "manual" }); | |
| 508 | const seen = () => { | |
| 509 | const l = getListing(db, me, { id }); | |
| 510 | if (!l.ok) throw new Error(l.message); | |
| 511 | return l.value.assets.find((a) => a.kind === "social_short")!; | |
| 512 | }; | |
| 513 | const me: Who = { role: "user", id: "dana", name: "dana" }; | |
| 514 | approveAsset(db, me, { id, kind: "social_short", seen: seen().updated_at }); | |
| 515 | expect(seen().approval).not.toBeNull(); | |
| 516 | ||
| 517 | const page = await (await fetch(`${base}/listings/${id}`, { headers: carol })).text(); | |
| 518 | expect(page).toContain("Use “near parks and schools”"); | |
| 519 | expect(page).toContain("Use “guest suite”"); | |
| 520 | // "a Christian home" is advice, not a phrase: no button for it. | |
| 521 | expect(page).not.toContain("describe the home, not the faith of who might buy it”"); | |
| 522 | expect((await (await fetch(`${base}/listings/${id}`)).text())).not.toContain("Use “"); | |
| 523 | ||
| 524 | const start = text.indexOf("family-friendly"); | |
| 525 | const tap = (who: Record<string, string>, fields: Record<string, string>, accept?: string) => | |
| 526 | fetch(`${base}/listings/${id}/assets/social_short/suggestion`, { | |
| 527 | method: "POST", | |
| 528 | body: new URLSearchParams(fields), | |
| 529 | headers: { ...who, ...(accept ? { accept } : {}) }, | |
| 530 | redirect: "manual", | |
| 531 | }); | |
| 532 | const at = { start: String(start), end: String(start + 15), text: "family-friendly" }; | |
| 533 | ||
| 534 | // Someone else is refused, and nothing moves. | |
| 535 | expect((await tap(bob, at)).status).toBe(303); | |
| 536 | expect(seen().text).toBe(text); | |
| 537 | expect((await tap(bob, at, "application/json")).status).toBe(403); | |
| 538 | // A stale place names the failure and changes nothing. | |
| 539 | const stale = await tap(carol, { ...at, start: String(start + 1), end: String(start + 16) }, "application/json"); | |
| 540 | expect(stale.status).toBe(409); | |
| 541 | expect(((await stale.json()) as { error: string }).error).toBe("conflict"); | |
| 542 | expect((await tap(carol, { ...at, text: "kid-friendly" }, "application/json")).status).toBe(409); | |
| 543 | // Advice is not a drop-in. | |
| 544 | const faith = text.indexOf("Christian"); | |
| 545 | expect((await tap(carol, { start: String(faith), end: String(faith + 9), text: "Christian" }, "application/json")).status).toBe(422); | |
| 546 | expect(seen().text).toBe(text); | |
| 547 | ||
| 548 | // The author's tap lands: only those words, the check reruns, approval clears. | |
| 549 | const done = await tap(carol, at, "application/json"); | |
| 550 | expect(done.status).toBe(200); | |
| 551 | const body = (await done.json()) as { text: string; html: string }; | |
| 552 | expect(body.text).toBe(text.replace("family-friendly", "near parks and schools")); | |
| 553 | expect(body.html).not.toContain("family-friendly"); | |
| 554 | expect(seen().text).toBe(body.text); | |
| 555 | expect(seen().approval).toBeNull(); | |
| 556 | expect(seen().check.findings.some((f) => f.text === "family-friendly")).toBe(false); | |
| 557 | // The same tap again is stale now; and the form POST without JS redirects back. | |
| 558 | expect((await tap(carol, at, "application/json")).status).toBe(409); | |
| 559 | const guest = body.text.indexOf("mother-in-law suite"); | |
| 560 | const plain = await tap(carol, { start: String(guest), end: String(guest + 19), text: "mother-in-law suite" }); | |
| 561 | expect(plain.status).toBe(303); | |
| 562 | expect(plain.headers.get("location")).toBe(`/listings/${id}?tab=social#social_short`); | |
| 563 | expect(seen().text).toContain("Bring your guest suite."); | |
| 564 | const audit = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE verb = 'update' ORDER BY id DESC LIMIT 8").all().map((r) => r.outcome); | |
| 565 | expect(audit).toContain("ok"); | |
| 566 | expect(audit).toContain("forbidden"); | |
| 567 | expect(audit).toContain("conflict"); | |
| 568 | }); |
src/mcp.ts +25
| @@ -14,6 +14,7 @@ import { | ||
| 14 | 14 | deleteListing, |
| 15 | 15 | deleteNote, |
| 16 | 16 | editAsset, |
| 17 | useSuggestion, | |
| 17 | 18 | getListing, |
| 18 | 19 | launchListing, |
| 19 | 20 | listListings, |
| @@ -145,6 +146,30 @@ export const tools: readonly Tool[] = [ | ||
| 145 | 146 | text: args.text, |
| 146 | 147 | }), |
| 147 | 148 | }, |
| 149 | { | |
| 150 | name: "use_suggestion", | |
| 151 | description: | |
| 152 | "Take a Fair Housing finding's suggested wording: replace exactly the words at start..end with the rule's drop-in phrase (get_listing shows each finding's start, end, text and dropIn). Refused as a conflict if the words changed since you read them, and as invalid where the finding has advice rather than a phrase. Saves like an edit: checked again, audited, approval cleared. Only the listing's agent or an admin can.", | |
| 153 | inputSchema: { | |
| 154 | type: "object", | |
| 155 | properties: { | |
| 156 | id: { type: "integer" }, | |
| 157 | kind: { enum: [...KINDS] }, | |
| 158 | start: { type: "integer" }, | |
| 159 | end: { type: "integer" }, | |
| 160 | text: { type: "string", description: "The flagged words as the finding showed them." }, | |
| 161 | }, | |
| 162 | required: ["id", "kind", "start", "end", "text"], | |
| 163 | }, | |
| 164 | run: (db, who, args) => | |
| 165 | useSuggestion(db, who, { | |
| 166 | id: Number(args.id), | |
| 167 | kind: String(args.kind ?? ""), | |
| 168 | start: args.start, | |
| 169 | end: args.end, | |
| 170 | text: args.text, | |
| 171 | }), | |
| 172 | }, | |
| 148 | 173 | { |
| 149 | 174 | name: "approve_asset", |
| 150 | 175 | description: |
src/pages/listing.ts +27 −7
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * flyer, which is also a page of its own for printing (`flyerPage`). |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import type { Check } from "../fairhousing.ts"; | |
| 7 | import type { Check, Finding } from "../fairhousing.ts"; | |
| 8 | 8 | import { |
| 9 | 9 | count, |
| 10 | 10 | factsLine, |
| @@ -36,8 +36,28 @@ export function annotate(text: string, check: Check): string { | ||
| 36 | 36 | return out + escape(text.slice(at)); |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | /** The check beside an asset: green, or amber with the words and why. The live check swaps this in whole. */ | |
| 40 | export function fairHousing(text: string, check: Check): string { | |
| 39 | /** Where a finding's suggested wording is taken: a form POST that replaces exactly those words. */ | |
| 40 | export const suggestionHref = (id: number, kind: string): string => | |
| 41 | `/listings/${id}/assets/${kind}/suggestion`; | |
| 42 | ||
| 43 | /** One tap on a finding's rewrite: the place and the words as seen, so a stale page cannot replace other words. */ | |
| 44 | const useButton = (f: Finding, to: string): string => | |
| 45 | f.dropIn === null | |
| 46 | ? "" | |
| 47 | : ` <form class="fh-fix" method="post" action="${to}" data-fix> | |
| 48 | <input type="hidden" name="start" value="${f.start}"> | |
| 49 | <input type="hidden" name="end" value="${f.end}"> | |
| 50 | <input type="hidden" name="text" value="${escape(f.text)}"> | |
| 51 | <button class="button button-quiet">Use “${escape(f.dropIn)}”</button> | |
| 52 | </form>`; | |
| 53 | ||
| 54 | /** | |
| 55 | * The check beside an asset: green, or amber with the words and why. The live | |
| 56 | * check swaps this in whole. With `fix` (where suggestions are posted) each | |
| 57 | * finding that has a drop-in rewrite carries a button for it; the live check | |
| 58 | * passes none, because what is typed is not yet what is stored. | |
| 59 | */ | |
| 60 | export function fairHousing(text: string, check: Check, fix?: string): string { | |
| 41 | 61 | if (check.clear) |
| 42 | 62 | return `<p class="fh-status fh-ok" role="status">✓ Fair Housing: clear</p>`; |
| 43 | 63 | const n = check.findings.length; |
| @@ -45,7 +65,7 @@ export function fairHousing(text: string, check: Check): string { | ||
| 45 | 65 | <ul class="fh-list">${check.findings |
| 46 | 66 | .map( |
| 47 | 67 | (f) => |
| 48 | `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)}</li>`, | |
| 68 | `<li><span class="fh-tag">${f.level === "note" ? "note" : escape(f.touches)}</span> ${escape(f.line)}${fix === undefined ? "" : useButton(f, fix)}</li>`, | |
| 49 | 69 | ) |
| 50 | 70 | .join("")}</ul> |
| 51 | 71 | <div class="fh-text" aria-label="The words, with what was flagged underlined">${annotate(text, check)}</div>`; |
| @@ -68,7 +88,7 @@ export const draftBand = (l: Launched, kind: "flyer" | "email"): string => | ||
| 68 | 88 | : ""; |
| 69 | 89 | |
| 70 | 90 | /** The approve control beside Copy: a button naming the flags, or who signed off and a way to take it back. */ |
| 71 | function approval(l: Launched, a: Asset): string { | |
| 91 | export function approval(l: Launched, a: Asset): string { | |
| 72 | 92 | const to = `/listings/${l.id}/assets/${a.kind}/approve`; |
| 73 | 93 | if (a.approval !== null) |
| 74 | 94 | return `<span class="approved" data-approved>✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}</span> |
| @@ -88,12 +108,12 @@ function editor(l: Launched, a: Asset, editable: boolean): string { | ||
| 88 | 108 | <div class="row asset-actions"> |
| 89 | 109 | ${editable ? `<button class="button">Save</button>` : ""} |
| 90 | 110 | <button type="button" class="button button-quiet" data-copy hidden>Copy</button> |
| 91 | ${editable ? approval(l, a) : a.approval === null ? "" : `<span class="approved">✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}</span>`} | |
| 111 | ${editable ? `<span data-approval>${approval(l, a)}</span>` : a.approval === null ? "" : `<span class="approved">✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}</span>`} | |
| 92 | 112 | <small class="muted" data-count>${a.text.length} characters</small> |
| 93 | 113 | </div> |
| 94 | 114 | </form> |
| 95 | 115 | </div> |
| 96 | <aside class="fh" data-fh="${a.kind}" aria-live="polite">${fairHousing(a.text, a.check)}</aside> | |
| 116 | <aside class="fh" data-fh="${a.kind}" aria-live="polite">${fairHousing(a.text, a.check, editable ? suggestionHref(l.id, a.kind) : undefined)}</aside> | |
| 97 | 117 | </section>`; |
| 98 | 118 | } |
| 99 | 119 |
src/routes.ts +31 −1
| @@ -32,6 +32,7 @@ import { | ||
| 32 | 32 | listNotes, |
| 33 | 33 | retoneListing, |
| 34 | 34 | updateNote, |
| 35 | useSuggestion, | |
| 35 | 36 | type Failure, |
| 36 | 37 | type Result, |
| 37 | 38 | type Upload, |
| @@ -58,7 +59,7 @@ import { rpc } from "./mcp.ts"; | ||
| 58 | 59 | import { draftOf, home, type Draft } from "./pages/home.ts"; |
| 59 | 60 | import { layout } from "./pages/layout.ts"; |
| 60 | 61 | import { emailPage } from "./pages/email.ts"; |
| 61 | import { fairHousing, flyerPage, listingPage, tabOf } from "./pages/listing.ts"; | |
| 62 | import { approval, fairHousing, flyerPage, listingPage, suggestionHref, tabOf } from "./pages/listing.ts"; | |
| 62 | 63 | import { notesPage } from "./pages/notes.ts"; |
| 63 | 64 | import { pathOf, PHOTOS_MAX, PHOTOS_TOTAL, typeOf } from "./photos.ts"; |
| 64 | 65 | import { tokensCss } from "./tokens.ts"; |
| @@ -471,6 +472,35 @@ export const routes: readonly Route[] = [ | ||
| 471 | 472 | return back(edited.ok ? "Saved and checked." : edited.message, to); |
| 472 | 473 | }, |
| 473 | 474 | }, |
| 475 | { | |
| 476 | method: "POST", | |
| 477 | path: "/listings/:id/assets/:kind/suggestion", | |
| 478 | handle: async (c) => { | |
| 479 | const form = await readForm(c.request); | |
| 480 | if (form === undefined) return text("too large", 413); | |
| 481 | const id = Number(c.params.id); | |
| 482 | const kind = c.params.kind ?? ""; | |
| 483 | const done = useSuggestion(c.db, c.who, { | |
| 484 | id, | |
| 485 | kind, | |
| 486 | start: Number(form.get("start") ?? ""), | |
| 487 | end: Number(form.get("end") ?? ""), | |
| 488 | text: form.get("text"), | |
| 489 | }); | |
| 490 | // The page's script asks for JSON and swaps the box and the aside in place. | |
| 491 | if ((c.request.headers.get("accept") ?? "").includes("application/json")) { | |
| 492 | if (!done.ok) return answer(done); | |
| 493 | const a = done.value.assets.find((x) => x.kind === kind)!; | |
| 494 | return json({ | |
| 495 | text: a.text, | |
| 496 | html: fairHousing(a.text, a.check, suggestionHref(id, kind)), | |
| 497 | approval: approval(done.value, a), | |
| 498 | }); | |
| 499 | } | |
| 500 | const to = `/listings/${id}?tab=${tabOf(kind)}#${encodeURIComponent(kind)}`; | |
| 501 | return back(done.ok ? "Saved and checked." : done.message, to); | |
| 502 | }, | |
| 503 | }, | |
| 474 | 504 | { |
| 475 | 505 | method: "POST", |
| 476 | 506 | path: "/listings/:id/assets/:kind/approve", |
src/server.test.ts +1
| @@ -149,6 +149,7 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 149 | 149 | "get_listing", |
| 150 | 150 | "launch_listing", |
| 151 | 151 | "edit_asset", |
| 152 | "use_suggestion", | |
| 152 | 153 | "approve_asset", |
| 153 | 154 | "retone_listing", |
| 154 | 155 | "delete_listing", |
src/tty.test.ts +25 −1
| @@ -7,7 +7,7 @@ import { expect, 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 | import { getListing, insertListing } from "./actions.ts"; | |
| 10 | import { editAsset, getListing, insertListing } from "./actions.ts"; | |
| 11 | 11 | import { openDb } from "./db.ts"; |
| 12 | 12 | import { SAMPLE } from "./sample.ts"; |
| 13 | 13 | import { fromLine, session, toLine } from "./tty.ts"; |
| @@ -157,3 +157,27 @@ test("the listings view says what is approved, and the email preview is a draft | ||
| 157 | 157 | tty.press("\x1b"); |
| 158 | 158 | expect(plain(tty.screen())).toContain("Ready to send"); |
| 159 | 159 | }); |
| 160 | ||
| 161 | test("u takes the suggested wording on the asset under the cursor, and says when there is none", () => { | |
| 162 | const { db, id } = fresh(); | |
| 163 | const tty = session(db, me); | |
| 164 | tty.press("\r"); | |
| 165 | tty.press("u"); | |
| 166 | expect(plain(tty.screen())).toContain("Nothing here has a suggested wording to use."); | |
| 167 | editAsset(db, me, { id, kind: "mls", text: "A family-friendly street. A Christian home." }); | |
| 168 | tty.press("u"); | |
| 169 | expect(plain(tty.screen())).toContain("Used “near parks and schools”"); | |
| 170 | const mls = () => { | |
| 171 | const l = getListing(db, me, { id }); | |
| 172 | return l.ok ? l.value.assets.find((a) => a.kind === "mls")!.text : ""; | |
| 173 | }; | |
| 174 | expect(mls()).toBe("A near parks and schools street. A Christian home."); | |
| 175 | tty.press("u"); // what is left is advice | |
| 176 | expect(plain(tty.screen())).toContain("Nothing here has a suggested wording"); | |
| 177 | const guest = session(db, other); | |
| 178 | guest.press("\r"); | |
| 179 | editAsset(db, me, { id, kind: "mls", text: "A family-friendly street." }); | |
| 180 | guest.press("u"); | |
| 181 | expect(plain(guest.screen())).toContain("Only the listing's agent or an admin can edit it."); | |
| 182 | expect(mls()).toBe("A family-friendly street."); | |
| 183 | }); |
src/tty.ts +13 −3
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | listListings, |
| 30 | 30 | listNotes, |
| 31 | 31 | retoneListing, |
| 32 | useSuggestion, | |
| 32 | 33 | } from "./actions.ts"; |
| 33 | 34 | import { ssh, title, where } from "./env.ts"; |
| 34 | 35 | import { approvalLabel } from "./listing.ts"; |
| @@ -110,7 +111,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 110 | 111 | if (previewing) help = "esc back to the assets · q quit"; |
| 111 | 112 | else help = |
| 112 | 113 | found?.ok === true && canEditListing(me, found.value) |
| 113 | ? "j/k asset · e edit · a approve/withdraw · t next tone · p preview email · esc back · q quit" | |
| 114 | ? "j/k asset · e edit · a approve/withdraw · u use suggested wording · t next tone · p preview email · esc back · q quit" | |
| 114 | 115 | : "j/k asset · p preview email · esc back · q quit"; |
| 115 | 116 | } else if (view === "listings") { |
| 116 | 117 | const rows = listings(); |
| @@ -203,10 +204,19 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 203 | 204 | if (previewing) return undefined; |
| 204 | 205 | if (down) cursor = Math.min(cursor + 1, Math.max(0, assets.length - 1)); |
| 205 | 206 | if (up) cursor = Math.max(cursor - 1, 0); |
| 206 | if (found?.ok === true && (key === "e" || key === "t" || key === "a")) { | |
| 207 | if (found?.ok === true && (key === "e" || key === "t" || key === "a" || key === "u")) { | |
| 207 | 208 | if (!canEditListing(me, found.value)) |
| 208 | 209 | said = me.role === "anon" ? "Sign in to edit this listing." : "Only the listing's agent or an admin can edit it."; |
| 209 | else if (key === "a") { | |
| 210 | else if (key === "u") { | |
| 211 | // The first finding on this asset that has a drop-in rewrite, taken as the page's button takes it. | |
| 212 | const asset = assets[cursor]; | |
| 213 | const finding = asset?.check.findings.find((f) => f.dropIn !== null); | |
| 214 | if (asset === undefined || finding === undefined) said = "Nothing here has a suggested wording to use."; | |
| 215 | else { | |
| 216 | const done = useSuggestion(db, me, { id: found.value.id, kind: asset.kind, start: finding.start, end: finding.end, text: finding.text }); | |
| 217 | said = done.ok ? green(`Used “${finding.dropIn}”. Saved and checked; approval cleared.`) : done.message; | |
| 218 | } | |
| 219 | } else if (key === "a") { | |
| 210 | 220 | const asset = assets[cursor]; |
| 211 | 221 | if (asset !== undefined) { |
| 212 | 222 | const withdrawing = asset.approval !== null; |
static/app.css +4
| @@ -464,6 +464,10 @@ h3 { | ||
| 464 | 464 | text-transform: uppercase; |
| 465 | 465 | letter-spacing: 0.04em; |
| 466 | 466 | } |
| 467 | .fh-fix { | |
| 468 | display: inline-block; | |
| 469 | margin-inline-start: var(, 0.5rem); | |
| 470 | } | |
| 467 | 471 | .fh-text { |
| 468 | 472 | white-space: pre-wrap; |
| 469 | 473 | overflow-wrap: anywhere; |
static/listing.js +25
| @@ -50,8 +50,33 @@ for (const area of document.querySelectorAll("textarea[data-check]")) { | ||
| 50 | 50 | const aside = document.querySelector(`[data-fh="${kind}"]`); |
| 51 | 51 | const counter = area.closest("form").querySelector("[data-count]"); |
| 52 | 52 | const approve = area.closest("form").querySelector("[data-approve]"); |
| 53 | const approval = area.closest("form").querySelector("[data-approval]"); | |
| 53 | 54 | let timer; |
| 54 | 55 | let asked = 0; |
| 56 | // One tap on a rewrite: the server replaces those words in what is stored | |
| 57 | // and answers with the new words, the check and the approval control. A | |
| 58 | // refusal is shown where the aside was; without this the form posts as is. | |
| 59 | aside.addEventListener("submit", async (e) => { | |
| 60 | const form = e.target.closest("[data-fix]"); | |
| 61 | if (!form) return; | |
| 62 | e.preventDefault(); | |
| 63 | asked++; | |
| 64 | const response = await fetch(form.action, { | |
| 65 | method: "POST", | |
| 66 | headers: { accept: "application/json" }, | |
| 67 | body: new URLSearchParams(new FormData(form)), | |
| 68 | }); | |
| 69 | const body = await response.json(); | |
| 70 | if (!response.ok) { | |
| 71 | aside.insertAdjacentHTML("afterbegin", `<p class="fh-status fh-warn" role="alert"></p>`); | |
| 72 | aside.firstElementChild.textContent = body.message; | |
| 73 | return; | |
| 74 | } | |
| 75 | area.value = body.text; | |
| 76 | aside.innerHTML = body.html; | |
| 77 | if (approval) approval.innerHTML = body.approval; | |
| 78 | if (counter) counter.textContent = `${area.value.length} characters`; | |
| 79 | }); | |
| 55 | 80 | area.addEventListener("input", () => { |
| 56 | 81 | if (approve) approve.disabled = true; |
| 57 | 82 | if (counter) counter.textContent = `${area.value.length} characters · not saved`; |
new above
offline
useSuggestion has no caller in src/tty.ts or src/mcp.ts, and no row in src/contract.test.ts. Wire both doors and test them.