Public: anyone can read this; the crew can change it.
The MLS description shows its length against the MLS public remarks limit, and one over it cannot be approved without saying so #109
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/106-the-mls-description-shows-its-length-aga → main 2 commits fork
12 files
changed +154
−32
README.md +6
| @@ -248,6 +248,12 @@ list cannot drift from the app. | ||
| 248 | 248 | `approved_too_long_for_x`. The terminal shows the same line and note under |
| 249 | 249 | each social post; `a` on a short post over X asks once, and a second `a` |
| 250 | 250 | approves it. |
| 251 | The MLS description shows `N / 1,000 characters` beside its box, counted in | |
| 252 | code points and live as you type; over 1,000 (the common MLS public-remarks | |
| 253 | cap, `MLS_LIMIT` in `src/postlength.ts`) it needs the same `tooLong` | |
| 254 | acknowledgement, on the page, the API, MCP and the terminal, and the audit | |
| 255 | trail records it as `approved_too_long_for_mls`. (The audit trail is where | |
| 256 | it is recorded; no seller report reads audit outcomes, so none shows it.) | |
| 251 | 257 | Where a finding's rewrite is a phrase that can stand in its place, the |
| 252 | 258 | aside has a **Use "near parks and schools"** button beside it (only for |
| 253 | 259 | whoever may edit the listing; advice such as "describe the home, not the |
contract/openapi.json +2 −2
| @@ -618,7 +618,7 @@ | ||
| 618 | 618 | } |
| 619 | 619 | }, |
| 620 | 620 | "patch": { |
| 621 | "summary": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 621 | "summary": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page, and says so with tooLong when a short post is over X's 280 or the MLS description is over its 1,000-character remarks limit), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 622 | 622 | "operationId": "patchApiListingsId", |
| 623 | 623 | "parameters": [ |
| 624 | 624 | { |
| @@ -845,7 +845,7 @@ | ||
| 845 | 845 | }, |
| 846 | 846 | "responses": { |
| 847 | 847 | "200": { |
| 848 | "description": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 848 | "description": "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page, and says so with tooLong when a short post is over X's 280 or the MLS description is over its 1,000-character remarks limit), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", | |
| 849 | 849 | "content": { |
| 850 | 850 | "application/json": { |
| 851 | 851 | "schema": { |
src/actions.ts +12 −11
| @@ -36,7 +36,7 @@ import { publicOrigin } from "./env.ts"; | ||
| 36 | 36 | import { matchesStatus, matchesText, parseFind } from "./find.ts"; |
| 37 | 37 | import { kit } from "./kit.ts"; |
| 38 | 38 | import { emit } from "./live.ts"; |
| 39 | import { meantForX, overXNote, postLength } from "./postlength.ts"; | |
| 39 | import { overNote, overOutcome } from "./postlength.ts"; | |
| 40 | 40 | import { |
| 41 | 41 | accept as acceptPhoto, |
| 42 | 42 | type Kind as PhotoKind, |
| @@ -968,10 +968,10 @@ function restore( | ||
| 968 | 968 | return edit(db, who, { id: found.value.id, kind: input.kind, text: row.body }, "restored"); |
| 969 | 969 | } |
| 970 | 970 | |
| 971 | /** How far a short post is over X's limit; 0 for any other asset or one that fits. */ | |
| 972 | const overX = (l: Launched, kind: string): number => { | |
| 971 | /** The audit outcome for an approval that went through: plain, or naming the limit it was over. */ | |
| 972 | const approvedOutcome = (l: Launched, kind: string): string => { | |
| 973 | 973 | const a = l.assets.find((x) => x.kind === kind); |
| 974 | return a !== undefined && meantForX(a.kind) ? postLength(a.text).overX : 0; | |
| 974 | return (a !== undefined && overOutcome(a.kind, a.text)) || "approved"; | |
| 975 | 975 | }; |
| 976 | 976 | |
| 977 | 977 | /** |
| @@ -984,10 +984,11 @@ const overX = (l: Launched, kind: string): number => { | ||
| 984 | 984 | * is approved (`conflict`). `door` is where the call came through; a |
| 985 | 985 | * person's door (page, tty) is stored with an approval, and a caller an |
| 986 | 986 | * assistant can reach (api, mcp) is refused (`person_required`) when it |
| 987 | * approves, and may withdraw. A short post over X's limit is approved only | |
| 988 | * with `tooLong: true` (the page's button and the terminal's second `a` say | |
| 989 | * so), and the audit row then reads `approved_too_long_for_x`. Both | |
| 990 | * directions are audit rows, named in the outcome. | |
| 987 | * approves, and may withdraw. A short post over X's limit, or an MLS | |
| 988 | * description over its 1,000-character cap, is approved only with | |
| 989 | * `tooLong: true` (the page's button and the terminal's second `a` say so), | |
| 990 | * and the audit row then reads `approved_too_long_for_x` (`_for_mls` for the | |
| 991 | * MLS description). Both directions are audit rows, named in the outcome. | |
| 991 | 992 | */ |
| 992 | 993 | export function approveAsset( |
| 993 | 994 | db: Database, |
| @@ -1003,7 +1004,7 @@ export function approveAsset( | ||
| 1003 | 1004 | target: done.ok ? done.value.id : input.id, |
| 1004 | 1005 | outcome: done.ok |
| 1005 | 1006 | ? approving |
| 1006 | ? overX(done.value, input.kind) > 0 ? "approved_too_long_for_x" : "approved" | |
| 1007 | ? approvedOutcome(done.value, input.kind) | |
| 1007 | 1008 | : "unapproved" |
| 1008 | 1009 | : done.error, |
| 1009 | 1010 | }); |
| @@ -1042,10 +1043,10 @@ function approve( | ||
| 1042 | 1043 | "conflict", |
| 1043 | 1044 | "These words changed since you read them. Save your changes and read them again before approving.", |
| 1044 | 1045 | ); |
| 1045 | if (meantForX(asset.kind) && overXNote(asset.text) !== "" && input.tooLong !== true) | |
| 1046 | if (overNote(asset.kind, asset.text) !== "" && input.tooLong !== true) | |
| 1046 | 1047 | return fail( |
| 1047 | 1048 | "invalid", |
| 1048 | `${overXNote(asset.text)}. Approve it knowing so: send tooLong: true.`, | |
| 1049 | `${overNote(asset.kind, asset.text)}. Approve it knowing so: send tooLong: true.`, | |
| 1049 | 1050 | ); |
| 1050 | 1051 | db.query( |
| 1051 | 1052 | "UPDATE asset SET approved_by_id = ?1, approved_by_name = ?2, approved_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), approved_via = ?5 WHERE listing_id = ?3 AND kind = ?4", |
src/listings.test.ts +40 −1
| @@ -422,6 +422,12 @@ test("only a person approves: MCP refuses approving and audits it, still withdra | ||
| 422 | 422 | expect(audited.length).toBe(2); |
| 423 | 423 | expect(approveAsset(db, me, { id, kind: "mls", door: "api", seen: seen() })).toMatchObject({ ok: false, error: "person_required" }); |
| 424 | 424 | expect(via().approved_at).toBeNull(); |
| 425 | // Over the MLS limit with the acknowledgement, still never through the API or MCP. | |
| 426 | editAsset(db, me, { id, kind: "mls", text: "a".repeat(1001) }); | |
| 427 | expect(approveAsset(db, me, { id, kind: "mls", door: "api", seen: seen(), tooLong: true })).toMatchObject({ ok: false, error: "person_required" }); | |
| 428 | expect(approveAsset(db, me, { id, kind: "mls", door: "mcp", seen: seen(), tooLong: true })).toMatchObject({ ok: false, error: "person_required" }); | |
| 429 | expect(via().approved_at).toBeNull(); | |
| 430 | editAsset(db, me, { id, kind: "mls", text: "a fair, short description" }); | |
| 425 | 431 | |
| 426 | 432 | approveAsset(db, me, { id, kind: "mls", door: "page", seen: seen() }); |
| 427 | 433 | expect(via().approved_via).toBe("page"); |
| @@ -900,7 +906,7 @@ test("each social post shows its length; a short post over X says so and its App | ||
| 900 | 906 | const mls = await fetch(`${base}/fair-housing`, { |
| 901 | 907 | method: "POST", |
| 902 | 908 | headers: { "content-type": "application/json" }, |
| 903 | body: JSON.stringify({ text: "a".repeat(314), kind: "mls" }), | |
| 909 | body: JSON.stringify({ text: "a".repeat(314), kind: "email_update" }), | |
| 904 | 910 | }); |
| 905 | 911 | expect(((await mls.json()) as { length: string | null }).length).toBeNull(); |
| 906 | 912 | }); |
| @@ -926,6 +932,39 @@ test("approving a short post over X needs tooLong, from any door, and the audit | ||
| 926 | 932 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x", "approved"]); |
| 927 | 933 | }); |
| 928 | 934 | |
| 935 | test("approving an MLS description over 1,000 characters needs tooLong, and the audit row says so", () => { | |
| 936 | const db = openDb(mkdtempSync(join(tmpdir(), "app-mlslong-"))); | |
| 937 | const me: Who = { role: "user", id: "dana", name: "Dana" }; | |
| 938 | const id = insertListing(db, me, SAMPLE, [], null).id; | |
| 939 | const stamp = () => { | |
| 940 | const l = getListing(db, me, { id }); | |
| 941 | return l.ok ? l.value.assets.find((a) => a.kind === "mls")!.updated_at : ""; | |
| 942 | }; | |
| 943 | const approve = (extra: { tooLong?: unknown } = {}) => approveAsset(db, me, { id, kind: "mls", door: "page", seen: stamp(), ...extra }); | |
| 944 | editAsset(db, me, { id, kind: "mls", text: "a".repeat(1000) }); | |
| 945 | expect(approve().ok).toBe(true); | |
| 946 | editAsset(db, me, { id, kind: "mls", text: "a".repeat(1001) }); | |
| 947 | const refused = approve(); | |
| 948 | expect(refused.ok).toBe(false); | |
| 949 | expect(refused.ok ? "" : refused.message).toContain("Too long for the MLS remarks by 1"); | |
| 950 | expect(approve({ tooLong: "yes" }).ok).toBe(false); | |
| 951 | expect(approve({ tooLong: true }).ok).toBe(true); | |
| 952 | const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%' ORDER BY id").all(); | |
| 953 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved", "approved_too_long_for_mls"]); | |
| 954 | }); | |
| 955 | ||
| 956 | test("the listing page renders the MLS count beside its box, and /fair-housing answers it", async () => { | |
| 957 | const first = (await (await fetch(`${base}/api/listings`)).json()) as { id: number }[]; | |
| 958 | const html = await (await fetch(`${base}/listings/${first[0]!.id}`)).text(); | |
| 959 | expect(html).toMatch(/data-count data-limit="1000">[\d,]+ \/ 1,000 characters</); | |
| 960 | const res = await fetch(`${base}/fair-housing`, { | |
| 961 | method: "POST", | |
| 962 | headers: { "content-type": "application/json" }, | |
| 963 | body: JSON.stringify({ text: "a".repeat(1001), kind: "mls" }), | |
| 964 | }); | |
| 965 | expect(((await res.json()) as { length: string }).length).toBe("1,001 / 1,000 characters"); | |
| 966 | }); | |
| 967 | ||
| 929 | 968 | test("the home list filters by status and needs-approval, searches the address, and escapes a hostile q", async () => { |
| 930 | 969 | const get = async (qs: string) => (await fetch(`${base}/${qs}`)).text(); |
| 931 | 970 | const cards = (html: string) => (html.match(/class="listing-card"/g) ?? []).length; |
src/pages/listing.ts +5 −5
| @@ -21,7 +21,7 @@ import { | ||
| 21 | 21 | } from "../generate.ts"; |
| 22 | 22 | import { asset as assetHref, escape } from "../http.ts"; |
| 23 | 23 | import { altOf, approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts"; |
| 24 | import { isPost, lengthLine, meantForX, overXNote } from "../postlength.ts"; | |
| 24 | import { countLine, MLS_LIMIT, isMls, overNote } from "../postlength.ts"; | |
| 25 | 25 | import { isSample } from "../photos.ts"; |
| 26 | 26 | import type { Who } from "../who.ts"; |
| 27 | 27 | import { dayOf } from "../showings.ts"; |
| @@ -86,7 +86,7 @@ export function fairHousing(text: string, check: Check, fix?: string): string { | ||
| 86 | 86 | |
| 87 | 87 | /** The aside's whole content for an asset: the length warning (a short post over X) above the Fair Housing check. */ |
| 88 | 88 | export function asideOf(kind: string, text: string, check: Check, fix?: string): string { |
| 89 | const over = meantForX(kind) ? overXNote(text) : ""; | |
| 89 | const over = overNote(kind, text); | |
| 90 | 90 | return `${over === "" ? "" : `<p class="fh-status fh-warn" role="status" data-too-long>${over}</p>`}${fairHousing(text, check, fix)}`; |
| 91 | 91 | } |
| 92 | 92 | |
| @@ -135,9 +135,9 @@ export function approval(l: Launched, a: Asset): string { | ||
| 135 | 135 | return `<span class="approved" data-approved>✓ Approved by ${escape(a.approval.by_name)} ${ago(a.approval.at)}${via(a.approval.via)}</span> |
| 136 | 136 | <button class="button button-quiet" formaction="${to}" name="approved" value="0" aria-label="Withdraw approval of the ${escape(a.label)}">Withdraw</button>`; |
| 137 | 137 | const n = a.check.findings.length; |
| 138 | const long = meantForX(a.kind) && overXNote(a.text) !== ""; | |
| 138 | const long = overNote(a.kind, a.text) !== ""; | |
| 139 | 139 | const label = n === 0 ? "Approve" : `Approve with ${n} ${n === 1 ? "flag" : "flags"}`; |
| 140 | const words = long ? `${label} — too long for X` : label; | |
| 140 | const words = long ? `${label} — too long for ${isMls(a.kind) ? "the MLS" : "X"}` : label; | |
| 141 | 141 | return `<input type="hidden" name="seen" value="${escape(a.updated_at)}">${long ? `\n <input type="hidden" name="too_long" value="1">` : ""} |
| 142 | 142 | <button class="button" data-approve aria-label="${words}: the ${escape(a.label)}" formaction="${to}" name="approved" value="1">${words}</button>`; |
| 143 | 143 | } |
| @@ -197,7 +197,7 @@ function editor(l: Launched, a: Asset, editable: boolean, versions: readonly Ver | ||
| 197 | 197 | ${editable ? `<button class="button" aria-label="Save the ${escape(a.label)}">Save</button>` : ""} |
| 198 | 198 | <button type="button" class="button button-quiet" data-copy data-asset="${escape(a.label)}" hidden aria-label="Copy the ${escape(a.label)}">Copy</button> |
| 199 | 199 | ${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)}${via(a.approval.via)}</span>`} |
| 200 | <small class="muted" data-count>${isPost(a.kind) ? escape(lengthLine(a.text)) : `${a.text.length} characters`}</small> | |
| 200 | <small class="muted${isMls(a.kind) ? " count-mls" : ""}" data-count${isMls(a.kind) ? ` data-limit="${MLS_LIMIT}"` : ""}>${escape(countLine(a.kind, a.text) ?? `${a.text.length} characters`)}</small> | |
| 201 | 201 | </div> |
| 202 | 202 | </form> |
| 203 | 203 | ${copyPost(l, a, origin)} |
src/postlength.test.ts +19 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { generate, TONES } from "./generate.ts"; |
| 3 | 3 | import { agentOf } from "./listing.ts"; |
| 4 | import { lengthLine, LIMITS, overXNote, postLength, xLength } from "./postlength.ts"; | |
| 4 | import { countLine, lengthLine, LIMITS, MLS_LIMIT, overMls, overXNote, postLength, xLength } from "./postlength.ts"; | |
| 5 | 5 | import { SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE } from "./sample.ts"; |
| 6 | 6 | |
| 7 | 7 | const link = "https://example.com/a/very/long/path/that/goes/on"; |
| @@ -53,3 +53,21 @@ test("the generated short post fits X in every tone for every sample listing", ( | ||
| 53 | 53 | expect([sample.address, tone, xLength(social_short) <= LIMITS.x]).toEqual([sample.address, tone, true]); |
| 54 | 54 | } |
| 55 | 55 | }); |
| 56 | ||
| 57 | test("the MLS description counts code points against one named limit", () => { | |
| 58 | expect(MLS_LIMIT).toBe(1000); | |
| 59 | expect(countLine("mls", "a".repeat(1000))).toBe("1,000 / 1,000 characters"); | |
| 60 | expect(countLine("mls", "😀".repeat(3))).toBe("3 / 1,000 characters"); | |
| 61 | expect(countLine("email_update", "x")).toBeNull(); | |
| 62 | expect(overMls("mls", "a".repeat(1000))).toBe(0); | |
| 63 | expect(overMls("mls", "a".repeat(1001))).toBe(1); | |
| 64 | expect(overMls("social_short", "a".repeat(1001))).toBe(0); | |
| 65 | }); | |
| 66 | ||
| 67 | test("the three sample listings' MLS descriptions stay under the limit in every tone", () => { | |
| 68 | for (const sample of [SAMPLE, SAMPLE_CONDO, SAMPLE_HOUSE]) | |
| 69 | for (const tone of TONES) { | |
| 70 | const { mls } = generate({ facts: { ...sample, tone }, agent: agentOf(sample) }); | |
| 71 | expect([sample.address, tone, overMls("mls", mls)]).toEqual([sample.address, tone, 0]); | |
| 72 | } | |
| 73 | }); |
src/postlength.ts +27
| @@ -6,6 +6,9 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | export const LIMITS = { x: 280, instagram: 2200, facebook: 63206 } as const; |
| 9 | /** The common MLS public-remarks cap: what an MLS keeps of the description, counted in code points. */ | |
| 10 | export const MLS_LIMIT = 1000; | |
| 11 | ||
| 9 | 12 | export type Network = keyof typeof LIMITS; |
| 10 | 13 | |
| 11 | 14 | export const NETWORK_NAMES: Record<Network, string> = { |
| @@ -83,3 +86,27 @@ export const overXNote = (text: string): string => { | ||
| 83 | 86 | const over = postLength(text).overX; |
| 84 | 87 | return over > 0 ? `Too long for X by ${over}` : ""; |
| 85 | 88 | }; |
| 89 | ||
| 90 | export const isMls = (kind: string): boolean => kind === "mls"; | |
| 91 | ||
| 92 | /** How many characters the MLS description is over the remarks limit; 0 when it fits or for another asset. */ | |
| 93 | export const overMls = (kind: string, text: string): number => | |
| 94 | isMls(kind) ? Math.max(0, points(text) - MLS_LIMIT) : 0; | |
| 95 | ||
| 96 | /** What approving is refused for until the agent says so: a short post over X, or an MLS description over its cap; "" when it fits. */ | |
| 97 | export function overNote(kind: string, text: string): string { | |
| 98 | if (meantForX(kind)) return overXNote(text); | |
| 99 | const over = overMls(kind, text); | |
| 100 | return over > 0 ? `Too long for the MLS remarks by ${over}` : ""; | |
| 101 | } | |
| 102 | ||
| 103 | /** The audit outcome of an approval of words that are over a limit; "" when they fit. */ | |
| 104 | export const overOutcome = (kind: string, text: string): string => | |
| 105 | overNote(kind, text) === "" ? "" : isMls(kind) ? "approved_too_long_for_mls" : "approved_too_long_for_x"; | |
| 106 | ||
| 107 | /** The count shown beside an asset's box: a post's lengths, the MLS description's `N / 1,000`, else null. */ | |
| 108 | export function countLine(kind: string, text: string): string | null { | |
| 109 | if (isPost(kind)) return lengthLine(text); | |
| 110 | if (isMls(kind)) return `${points(text).toLocaleString("en-US")} / ${MLS_LIMIT.toLocaleString("en-US")} characters`; | |
| 111 | return null; | |
| 112 | } |
src/routes.ts +4 −4
| @@ -80,7 +80,7 @@ import { INTERESTS, SHOWING_AGENT_MAX, SHOWING_FEEDBACK_MAX } from "./showings.t | ||
| 80 | 80 | import { emailPage } from "./pages/email.ts"; |
| 81 | 81 | import { publicOrigin } from "./env.ts"; |
| 82 | 82 | import { propertyPage } from "./pages/property.ts"; |
| 83 | import { isPost, lengthLine } from "./postlength.ts"; | |
| 83 | import { countLine } from "./postlength.ts"; | |
| 84 | 84 | import { reportPage } from "./pages/report.ts"; |
| 85 | 85 | import { schedulePage } from "./pages/schedule.ts"; |
| 86 | 86 | import { scheduleIcs, weekIcs } from "./schedule.ts"; |
| @@ -849,7 +849,7 @@ export const routes: readonly Route[] = [ | ||
| 849 | 849 | return json({ |
| 850 | 850 | text: a.text, |
| 851 | 851 | html: asideOf(kind, a.text, a.check, suggestionHref(id, kind)), |
| 852 | length: isPost(kind) ? lengthLine(a.text) : null, | |
| 852 | length: countLine(kind, a.text), | |
| 853 | 853 | approval: approval(done.value, a), |
| 854 | 854 | }); |
| 855 | 855 | } |
| @@ -939,7 +939,7 @@ export const routes: readonly Route[] = [ | ||
| 939 | 939 | return json({ |
| 940 | 940 | ...checked, |
| 941 | 941 | html: asideOf(kind, shown, checked), |
| 942 | length: isPost(kind) ? lengthLine(shown) : null, | |
| 942 | length: countLine(kind, shown), | |
| 943 | 943 | }); |
| 944 | 944 | }, |
| 945 | 945 | }, |
| @@ -1148,7 +1148,7 @@ export const routes: readonly Route[] = [ | ||
| 1148 | 1148 | : editAsset(c.db, c.who, { id, kind: String(body.kind ?? ""), text: body.text }), |
| 1149 | 1149 | ); |
| 1150 | 1150 | }, |
| 1151 | api: { summary: "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING }, | |
| 1151 | api: { summary: "Edit one asset's words, put an earlier version back ({ kind, restore }), withdraw an approval ({ kind, approved: false }; approving is refused, a person does it on the page, and says so with tooLong when a short post is over X's 280 or the MLS description is over its 1,000-character remarks limit), write every asset again in a tone, mark it coming soon, active, under contract, sold, withdrawn or expired ({ status, sold_price }), which adds or removes the status post and email, or change the facts ({ facts }), which writes every asset again and clears every approval.", body: LISTING_CHANGE, answer: LISTING }, | |
| 1152 | 1152 | }, |
| 1153 | 1153 | { |
| 1154 | 1154 | method: "DELETE", |
src/tty.test.ts +19
| @@ -226,6 +226,25 @@ test("the terminal shows a social post's length, and a short post over X needs a | ||
| 226 | 226 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x"]); |
| 227 | 227 | }); |
| 228 | 228 | |
| 229 | test("the terminal shows the MLS description's count, and one over 1,000 needs a second a", () => { | |
| 230 | const { db, id } = fresh(); | |
| 231 | expect(editAsset(db, me, { id, kind: "mls", text: "a".repeat(1003) }).ok).toBe(true); | |
| 232 | const tty = session(db, me); | |
| 233 | tty.press("\r"); // the MLS description is first | |
| 234 | const seen = plain(tty.screen()); | |
| 235 | expect(seen).toContain("1,003 / 1,000 characters"); | |
| 236 | expect(seen).toContain("! Too long for the MLS remarks by 3"); | |
| 237 | ||
| 238 | tty.press("a"); | |
| 239 | expect(plain(tty.screen())).toContain("Too long for the MLS remarks by 3. Press a again to approve it anyway."); | |
| 240 | expect(plain(tty.screen())).toContain("0 of 7 approved"); | |
| 241 | tty.press("a"); | |
| 242 | expect(plain(tty.screen())).toContain("Approved, too long for the MLS."); | |
| 243 | expect(plain(tty.screen())).toContain("1 of 7 approved"); | |
| 244 | const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%'").all(); | |
| 245 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_mls"]); | |
| 246 | }); | |
| 247 | ||
| 229 | 248 | test("f cycles the listings filter and / searches by address, as the page does", () => { |
| 230 | 249 | const { db } = fresh(); |
| 231 | 250 | const tty = session(db, me); |
src/tty.ts +6 −6
| @@ -61,7 +61,7 @@ import { reportText } from "./report.ts"; | ||
| 61 | 61 | import { scheduleText, weekText } from "./schedule.ts"; |
| 62 | 62 | import { now } from "./clock.ts"; |
| 63 | 63 | import { approvalLabel, propertyLine } from "./listing.ts"; |
| 64 | import { isPost, lengthLine, meantForX, overXNote } from "./postlength.ts"; | |
| 64 | import { countLine, isMls, overNote } from "./postlength.ts"; | |
| 65 | 65 | import { emailParts } from "./pages/email.ts"; |
| 66 | 66 | import { factItems, money, openHouseLine, TONES, type Kind, type OpenHouse } from "./generate.ts"; |
| 67 | 67 | import type { Who } from "./who.ts"; |
| @@ -214,8 +214,8 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 214 | 214 | const body = l.assets.slice(cursor).flatMap((a, i) => [ |
| 215 | 215 | `${i === 0 ? "▸" : " "} ${i === 0 ? bold(a.label) : a.label} ${a.check.clear ? green("Fair Housing: clear") : amber(`Fair Housing: ${a.check.findings.length} to review`)}${a.edited ? dim(" edited") : ""}${a.approval === null ? "" : green(` approved by ${a.approval.by_name}`)}`, |
| 216 | 216 | ...a.text.split("\n").map((line) => ` ${line}`), |
| 217 | ...(isPost(a.kind) ? [dim(` ${lengthLine(a.text)}`)] : []), | |
| 218 | ...(meantForX(a.kind) && overXNote(a.text) !== "" ? [amber(` ! ${overXNote(a.text)}`)] : []), | |
| 217 | ...(countLine(a.kind, a.text) === null ? [] : [dim(` ${countLine(a.kind, a.text)}`)]), | |
| 218 | ...(overNote(a.kind, a.text) !== "" ? [amber(` ! ${overNote(a.kind, a.text)}`)] : []), | |
| 219 | 219 | ...a.check.findings.map((f) => amber(` ! ${f.line}`)), |
| 220 | 220 | "", |
| 221 | 221 | ]); |
| @@ -474,14 +474,14 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 474 | 474 | if (asset !== undefined) { |
| 475 | 475 | const withdrawing = asset.approval !== null; |
| 476 | 476 | const stamp = `${asset.kind}@${asset.updated_at}`; |
| 477 | const long = !withdrawing && meantForX(asset.kind) && overXNote(asset.text) !== ""; | |
| 477 | const long = !withdrawing && overNote(asset.kind, asset.text) !== ""; | |
| 478 | 478 | if (long && asked !== stamp) { |
| 479 | 479 | longAsked = stamp; |
| 480 | said = amber(`${overXNote(asset.text)}. Press a again to approve it anyway.`); | |
| 480 | said = amber(`${overNote(asset.kind, asset.text)}. Press a again to approve it anyway.`); | |
| 481 | 481 | } else { |
| 482 | 482 | const done = approveAsset(db, me, { id: found.value.id, kind: asset.kind, approved: !withdrawing, seen: asset.updated_at, door: "tty", tooLong: long }); |
| 483 | 483 | said = done.ok |
| 484 | ? withdrawing ? "Approval withdrawn." : green(long ? "Approved, too long for X." : "Approved.") | |
| 484 | ? withdrawing ? "Approval withdrawn." : green(long ? `Approved, too long for ${isMls(asset.kind) ? "the MLS" : "X"}.` : "Approved.") | |
| 485 | 485 | : done.message; |
| 486 | 486 | } |
| 487 | 487 | } |
static/app.css +6
| @@ -1428,3 +1428,9 @@ main:focus { | ||
| 1428 | 1428 | margin: 0; |
| 1429 | 1429 | } |
| 1430 | 1430 | } |
| 1431 | ||
| 1432 | /* The MLS count: wide enough for "1,000 / 1,000 characters" so typing never moves the row. */ | |
| 1433 | .count-mls { | |
| 1434 | display: inline-block; | |
| 1435 | min-width: 24ch; | |
| 1436 | } |
static/listing.js +8 −2
| @@ -115,7 +115,13 @@ for (const area of document.querySelectorAll("textarea[data-check]")) { | ||
| 115 | 115 | }); |
| 116 | 116 | area.addEventListener("input", () => { |
| 117 | 117 | if (approve) approve.disabled = true; |
| 118 | if (counter) counter.textContent = `${area.value.length} characters · not saved`; | |
| 118 | if (counter) { | |
| 119 | const n = [...area.value].length; | |
| 120 | const limit = Number(counter.dataset.limit); | |
| 121 | counter.textContent = limit | |
| 122 | ? `${n.toLocaleString("en-US")} / ${limit.toLocaleString("en-US")} characters` | |
| 123 | : `${area.value.length} characters · not saved`; | |
| 124 | } | |
| 119 | 125 | clearTimeout(timer); |
| 120 | 126 | timer = setTimeout(async () => { |
| 121 | 127 | const mine = ++asked; |
| @@ -127,7 +133,7 @@ for (const area of document.querySelectorAll("textarea[data-check]")) { | ||
| 127 | 133 | if (!response.ok || mine !== asked) return; |
| 128 | 134 | const checked = await response.json(); |
| 129 | 135 | aside.innerHTML = checked.html; |
| 130 | if (counter && checked.length) counter.textContent = `${checked.length} · not saved`; | |
| 136 | if (counter && checked.length && !counter.dataset.limit) counter.textContent = `${checked.length} · not saved`; | |
| 131 | 137 | announce(aside.querySelector(".fh-status")?.textContent ?? ""); |
| 132 | 138 | }, 600); |
| 133 | 139 | }); |