Public: anyone can read this; the crew can change it.
Find a listing on the home list: filter by Active, Under contract, Sold or Needs approval, and search by address #51
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/48-find-a-listing-on-the-home-list-filter-b → main 5 commits fork 1 thread · 1 unresolved
13 files
changed +310
−27
README.md +6
| @@ -54,6 +54,9 @@ to make a listing of your own. | ||
| 54 | 54 | and needing its own approval. A raise or no change adds nothing; raising |
| 55 | 55 | the price back to the original, or above it, takes them away. The same |
| 56 | 56 | goes in the terminal (press `r` on the listing) and for other tools. |
| 57 | **Find one.** Above the list, **All · Active · Under contract · Sold · | |
| 58 | Needs approval** are links with their counts, and the search box matches | |
| 59 | the address, any case, together with the filter. | |
| 57 | 60 | 9. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with |
| 58 | 61 | the photo, price, facts, description and your name. Print it or save it |
| 59 | 62 | as a PDF. |
| @@ -84,6 +87,9 @@ list cannot drift from the app. | ||
| 84 | 87 | or make another the hero (24 MB of new files per save; the flyer and the email need approving again). |
| 85 | 88 | Over the JSON API, `PATCH` `{ photos: { order, remove } }` reorders and |
| 86 | 89 | removes; uploads are multipart on the page only. |
| 90 | `GET /api/listings?status=sold&q=oak` and the MCP `list_listings` tool take | |
| 91 | the same filter and search as the home list; an unknown status is All and | |
| 92 | `q` is capped at 100 characters. | |
| 87 | 93 | Your brand (name, brokerage, license number, phone, email, headshot, |
| 88 | 94 | colour) is asked once and carried to your next listing. |
| 89 | 95 | 2. **Pick a tone** — *classic* (measured), *warm* (inviting) or *bold* |
contract/openapi.json +25 −2
| @@ -298,7 +298,7 @@ | ||
| 298 | 298 | }, |
| 299 | 299 | "/api/listings": { |
| 300 | 300 | "get": { |
| 301 | "summary": "The listings, newest first, each with how many phrases the Fair Housing check flags.", | |
| 301 | "summary": "The listings, newest first, each with how many phrases the Fair Housing check flags. Filter by status and search the address.", | |
| 302 | 302 | "operationId": "getApiListings", |
| 303 | 303 | "parameters": [ |
| 304 | 304 | { |
| @@ -310,11 +310,34 @@ | ||
| 310 | 310 | "maximum": 500, |
| 311 | 311 | "default": 100 |
| 312 | 312 | } |
| 313 | }, | |
| 314 | { | |
| 315 | "name": "status", | |
| 316 | "in": "query", | |
| 317 | "schema": { | |
| 318 | "enum": [ | |
| 319 | "all", | |
| 320 | "active", | |
| 321 | "under_contract", | |
| 322 | "sold", | |
| 323 | "needs-approval" | |
| 324 | ], | |
| 325 | "description": "Only listings that are active, under_contract, sold or needs-approval; anything else is all." | |
| 326 | } | |
| 327 | }, | |
| 328 | { | |
| 329 | "name": "q", | |
| 330 | "in": "query", | |
| 331 | "schema": { | |
| 332 | "type": "string", | |
| 333 | "maxLength": 100, | |
| 334 | "description": "Only listings whose address contains this, any case." | |
| 335 | } | |
| 313 | 336 | } |
| 314 | 337 | ], |
| 315 | 338 | "responses": { |
| 316 | 339 | "200": { |
| 317 | "description": "The listings, newest first, each with how many phrases the Fair Housing check flags.", | |
| 340 | "description": "The listings, newest first, each with how many phrases the Fair Housing check flags. Filter by status and search the address.", | |
| 318 | 341 | "content": { |
| 319 | 342 | "application/json": { |
| 320 | 343 | "schema": { |
src/actions.ts +19 −11
| @@ -30,6 +30,7 @@ import { | ||
| 30 | 30 | type ListingInput, |
| 31 | 31 | type ListingRow, |
| 32 | 32 | } from "./listing.ts"; |
| 33 | import { matchesStatus, matchesText, parseFind } from "./find.ts"; | |
| 33 | 34 | import { kit } from "./kit.ts"; |
| 34 | 35 | import { emit } from "./live.ts"; |
| 35 | 36 | import { meantForX, overXNote, postLength } from "./postlength.ts"; |
| @@ -434,8 +435,9 @@ export type ListingSummary = Listing & { | ||
| 434 | 435 | export function listListings( |
| 435 | 436 | db: Database, |
| 436 | 437 | who: Who, |
| 437 | input: { limit?: number } = {}, | |
| 438 | input: { limit?: number; status?: unknown; q?: unknown } = {}, | |
| 438 | 439 | ): Result<ListingSummary[]> { |
| 440 | const wanted = parseFind(input.status, input.q); | |
| 439 | 441 | const limit = Math.min( |
| 440 | 442 | Math.max(Math.trunc(input.limit ?? 100) || 100, 1), |
| 441 | 443 | 500, |
| @@ -443,30 +445,36 @@ export function listListings( | ||
| 443 | 445 | const may = permit(who, "listing", "list"); |
| 444 | 446 | if (may === "no") |
| 445 | 447 | return refuse(who, "Sign in to see listings.", "You may not see these listings."); |
| 448 | // A filter looks at every row the person may see, then keeps the newest `limit` of what matched. | |
| 449 | const reach = wanted.status === "all" && wanted.q === "" ? limit : 500; | |
| 446 | 450 | const rows = |
| 447 | 451 | may === "yes" |
| 448 | 452 | ? db |
| 449 | 453 | .query<ListingRow, [number]>( |
| 450 | 454 | "SELECT * FROM listing ORDER BY id DESC LIMIT ?1", |
| 451 | 455 | ) |
| 452 | .all(limit) | |
| 456 | .all(reach) | |
| 453 | 457 | : db |
| 454 | 458 | .query<ListingRow, [number, string]>( |
| 455 | 459 | `SELECT * FROM listing WHERE ${may.where.map((c) => `${c} = ?2`).join(" OR ")} ORDER BY id DESC LIMIT ?1`, |
| 456 | 460 | ) |
| 457 | .all(limit, who.role === "anon" ? "" : who.id); | |
| 461 | .all(reach, who.role === "anon" ? "" : who.id); | |
| 458 | 462 | const bodies = db.query<{ kind: string; body: string; approved_at: string | null }, [number]>( |
| 459 | 463 | "SELECT kind, body, approved_at FROM asset WHERE listing_id = ?1 ORDER BY rowid", |
| 460 | 464 | ); |
| 461 | 465 | return ok( |
| 462 | rows.map((row) => { | |
| 463 | const assets = bodies.all(row.id); | |
| 464 | return { | |
| 465 | ...fromRow(row), | |
| 466 | flags: assets.reduce((n, a) => n + check(a.body).findings.length, 0), | |
| 467 | approvals: assets.map((a) => ({ kind: a.kind, approved: a.approved_at !== null })), | |
| 468 | }; | |
| 469 | }), | |
| 466 | rows | |
| 467 | .filter((row) => matchesText(row, wanted.q)) | |
| 468 | .map((row) => { | |
| 469 | const assets = bodies.all(row.id); | |
| 470 | return { | |
| 471 | ...fromRow(row), | |
| 472 | flags: assets.reduce((n, a) => n + check(a.body).findings.length, 0), | |
| 473 | approvals: assets.map((a) => ({ kind: a.kind, approved: a.approved_at !== null })), | |
| 474 | }; | |
| 475 | }) | |
| 476 | .filter((l) => matchesStatus(l, wanted.status)) | |
| 477 | .slice(0, limit), | |
| 470 | 478 | ); |
| 471 | 479 | } |
| 472 | 480 |
src/design.test.ts +19
| @@ -150,3 +150,22 @@ test("the phone rules are in the stylesheet and print is untouched by them", asy | ||
| 150 | 150 | expect(block).toContain(rule); |
| 151 | 151 | expect(block).not.toContain("@media print"); |
| 152 | 152 | }); |
| 153 | ||
| 154 | test("every rule in the stylesheet is closed: no rule opens inside another, and the braces balance", async () => { | |
| 155 | const css = (await Bun.file(join(import.meta.dir, "..", "static", "app.css")).text()).replace(/\/\*[\s\S]*?\*\//g, ""); | |
| 156 | const open: boolean[] = []; // per open block: is it an @-rule, which may hold rules | |
| 157 | let since = 0; | |
| 158 | for (let i = 0; i < css.length; i++) { | |
| 159 | if (css[i] === "{") { | |
| 160 | const head = css.slice(since, i).trim(); | |
| 161 | if (open.length > 0 && !open[open.length - 1]) throw new Error(`"${head}" opens inside an unclosed rule`); | |
| 162 | open.push(head.startsWith("@")); | |
| 163 | since = i + 1; | |
| 164 | } else if (css[i] === "}") { | |
| 165 | expect(open.length).toBeGreaterThan(0); | |
| 166 | open.pop(); | |
| 167 | since = i + 1; | |
| 168 | } else if (css[i] === ";") since = i + 1; | |
| 169 | } | |
| 170 | expect(open).toEqual([]); | |
| 171 | }); |
src/find.test.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { counts, find, parseFind, Q_MAX } from "./find.ts"; | |
| 3 | ||
| 4 | const rows = [ | |
| 5 | { address: "1 Oak Lane", status: "active" as const, approvals: [{ approved: true }] }, | |
| 6 | { address: "2 Elm Court", status: "sold" as const, approvals: [{ approved: false }] }, | |
| 7 | { address: "3 Oak Road", status: "under_contract" as const, approvals: [{ approved: true }] }, | |
| 8 | ]; | |
| 9 | ||
| 10 | test("status is validated and q is trimmed and capped", () => { | |
| 11 | expect(parseFind("nope", null).status).toBe("all"); | |
| 12 | expect(parseFind("under-contract", " Oak ")).toEqual({ status: "under_contract", q: "Oak" }); | |
| 13 | expect(parseFind("needs-approval", "").status).toBe("needs-approval"); | |
| 14 | expect(parseFind("all", "x".repeat(500)).q.length).toBe(Q_MAX); | |
| 15 | }); | |
| 16 | ||
| 17 | test("each filter, search, and both together", () => { | |
| 18 | const f = (s: string, q = "") => find(rows, parseFind(s, q)).map((r) => r.address); | |
| 19 | expect(f("all")).toHaveLength(3); | |
| 20 | expect(f("active")).toEqual(["1 Oak Lane"]); | |
| 21 | expect(f("under_contract")).toEqual(["3 Oak Road"]); | |
| 22 | expect(f("sold")).toEqual(["2 Elm Court"]); | |
| 23 | expect(f("needs-approval")).toEqual(["2 Elm Court"]); | |
| 24 | expect(f("all", "OAK")).toEqual(["1 Oak Lane", "3 Oak Road"]); | |
| 25 | expect(f("active", "oak")).toEqual(["1 Oak Lane"]); | |
| 26 | expect(counts(rows, "oak")).toEqual({ all: 2, active: 1, under_contract: 1, sold: 0, "needs-approval": 0 }); | |
| 27 | }); |
src/find.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * Finding a listing in the list: which filter, which words. Plain data in, | |
| 3 | * plain data out; the boundary (`parseFind`) is the only place a raw status | |
| 4 | * or search text is read, and the match is done in memory, never in SQL text. | |
| 5 | */ | |
| 6 | ||
| 7 | import { STATUSES, type Status } from "./generate.ts"; | |
| 8 | ||
| 9 | export const FILTERS = ["all", ...STATUSES, "needs-approval"] as const; | |
| 10 | export type Filter = (typeof FILTERS)[number]; | |
| 11 | ||
| 12 | export const Q_MAX = 100; | |
| 13 | ||
| 14 | export interface Find { | |
| 15 | readonly status: Filter; | |
| 16 | readonly q: string; | |
| 17 | } | |
| 18 | ||
| 19 | /** A status and a search from the wire: anything unknown is All, the text is trimmed and capped. */ | |
| 20 | export function parseFind(status: unknown, q: unknown): Find { | |
| 21 | const said = typeof status === "string" ? status.trim().toLowerCase().replace("-", "_") : ""; | |
| 22 | const found = FILTERS.find((f) => f.replace("-", "_") === said); | |
| 23 | return { | |
| 24 | status: found ?? "all", | |
| 25 | q: typeof q === "string" ? q.trim().slice(0, Q_MAX) : "", | |
| 26 | }; | |
| 27 | } | |
| 28 | ||
| 29 | interface Findable { | |
| 30 | readonly address: string; | |
| 31 | readonly status: Status; | |
| 32 | readonly approvals: readonly { readonly approved: boolean }[]; | |
| 33 | } | |
| 34 | ||
| 35 | export const matchesStatus = (l: Findable, status: Filter): boolean => | |
| 36 | status === "all" | |
| 37 | ? true | |
| 38 | : status === "needs-approval" | |
| 39 | ? l.approvals.some((a) => !a.approved) | |
| 40 | : l.status === status; | |
| 41 | ||
| 42 | export const matchesText = (l: Findable, q: string): boolean => | |
| 43 | q === "" || l.address.toLowerCase().includes(q.toLowerCase()); | |
| 44 | ||
| 45 | /** How many listings each filter would show, for the words already typed. */ | |
| 46 | export function counts(listings: readonly Findable[], q: string): Record<Filter, number> { | |
| 47 | const inText = listings.filter((l) => matchesText(l, q)); | |
| 48 | return Object.fromEntries( | |
| 49 | FILTERS.map((f) => [f, inText.filter((l) => matchesStatus(l, f)).length]), | |
| 50 | ) as Record<Filter, number>; | |
| 51 | } | |
| 52 | ||
| 53 | export const find = <T extends Findable>(listings: readonly T[], f: Find): T[] => | |
| 54 | listings.filter((l) => matchesText(l, f.q) && matchesStatus(l, f.status)); |
src/listings.test.ts +30
| @@ -919,3 +919,33 @@ test("approving a short post over X needs tooLong, from any door, and the audit | ||
| 919 | 919 | const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%' ORDER BY id").all(); |
| 920 | 920 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x", "approved"]); |
| 921 | 921 | }); |
| 922 | ||
| 923 | test("the home list filters by status and needs-approval, searches the address, and escapes a hostile q", async () => { | |
| 924 | const get = async (qs: string) => (await fetch(`${base}/${qs}`)).text(); | |
| 925 | const cards = (html: string) => (html.match(/class="listing-card"/g) ?? []).length; | |
| 926 | const all = await get(""); | |
| 927 | expect(all).toContain('aria-current="true">All'); | |
| 928 | expect(cards(all)).toBeGreaterThan(0); | |
| 929 | const sold = await get("?status=sold"); | |
| 930 | expect(sold).toContain('aria-current="true">Sold'); | |
| 931 | expect(cards(sold)).toBeGreaterThan(0); | |
| 932 | expect(cards(sold)).toBeLessThan(cards(all)); | |
| 933 | expect(await get("?status=bogus")).toContain('aria-current="true">All'); | |
| 934 | const needs = await get("?status=needs-approval"); | |
| 935 | expect(needs).toContain('aria-current="true">Needs approval'); | |
| 936 | // Search is case-insensitive and combined with the filter. | |
| 937 | const none = await get("?status=sold&q=zzzz-nowhere"); | |
| 938 | expect(none).toContain("No listing matches"); | |
| 939 | expect(none).toContain('<a href="/">Clear the search</a>'); | |
| 940 | const hostile = await get(`?q=${encodeURIComponent('"><script>alert(1)</script>')}`); | |
| 941 | expect(hostile).not.toContain("<script>alert(1)"); | |
| 942 | expect(hostile).toContain('value=""><script>alert(1)</script>"'); | |
| 943 | const first = (await (await fetch(`${base}/api/listings`)).json()) as { address: string; status: string }[]; | |
| 944 | const q = first[0]!.address.slice(0, 5).toUpperCase(); | |
| 945 | const hit = (await (await fetch(`${base}/api/listings?q=${encodeURIComponent(q)}`)).json()) as { address: string }[]; | |
| 946 | expect(hit.length).toBeGreaterThan(0); | |
| 947 | expect(hit.every((l) => l.address.toLowerCase().includes(q.toLowerCase()))).toBe(true); | |
| 948 | const soldApi = (await (await fetch(`${base}/api/listings?status=sold`)).json()) as { status: string }[]; | |
| 949 | expect(soldApi.every((l) => l.status === "sold")).toBe(true); | |
| 950 | expect(soldApi.length).toBe(cards(sold)); | |
| 951 | }); |
src/mcp.ts +8 −3
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { FILTERS, Q_MAX } from "./find.ts"; | |
| 10 | 11 | import { |
| 11 | 12 | addNote, |
| 12 | 13 | approveAsset, |
| @@ -95,13 +96,17 @@ export const tools: readonly Tool[] = [ | ||
| 95 | 96 | { |
| 96 | 97 | name: "list_listings", |
| 97 | 98 | description: |
| 98 | "The launched listings, newest first, each with how many phrases the Fair Housing check flags.", | |
| 99 | "The launched listings, newest first, each with how many phrases the Fair Housing check flags. Filter by status and search the address.", | |
| 99 | 100 | inputSchema: { |
| 100 | 101 | type: "object", |
| 101 | properties: { limit: { type: "integer", minimum: 1, maximum: 500 } }, | |
| 102 | properties: { | |
| 103 | limit: { type: "integer", minimum: 1, maximum: 500 }, | |
| 104 | status: { enum: [...FILTERS], description: "Only listings that are active, under_contract, sold or needs-approval; anything else is all." }, | |
| 105 | q: { type: "string", maxLength: Q_MAX, description: "Only listings whose address contains this, any case." }, | |
| 106 | }, | |
| 102 | 107 | }, |
| 103 | 108 | run: (db, who, args) => |
| 104 | listListings(db, who, { limit: Number(args.limit ?? 100) }), | |
| 109 | listListings(db, who, { limit: Number(args.limit ?? 100), status: args.status, q: args.q }), | |
| 105 | 110 | }, |
| 106 | 111 | { |
| 107 | 112 | name: "get_listing", |
src/pages/home.ts +39 −3
| @@ -5,8 +5,9 @@ | ||
| 5 | 5 | * placeholders, so an empty form still shows what goes where. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { count, money, TONES, type Tone } from "../generate.ts"; | |
| 8 | import { count, money, STATUS_LABELS, TONES, type Tone } from "../generate.ts"; | |
| 9 | 9 | import type { ListingSummary } from "../actions.ts"; |
| 10 | import { counts, Q_MAX, find, FILTERS, type Filter, type Find } from "../find.ts"; | |
| 10 | 11 | import { escape } from "../http.ts"; |
| 11 | 12 | import { |
| 12 | 13 | FEATURES_MAX, |
| @@ -64,10 +65,12 @@ export function draftOf(listing: Listing, brandOnly: boolean): Draft { | ||
| 64 | 65 | export function home(input: { |
| 65 | 66 | who: Who; |
| 66 | 67 | listings: readonly ListingSummary[]; |
| 68 | find?: Find; | |
| 67 | 69 | draft: Draft; |
| 68 | 70 | notice?: string | undefined; |
| 69 | 71 | }): string { |
| 70 | 72 | const { who, listings, draft, notice } = input; |
| 73 | const wanted = input.find ?? { status: "all", q: "" }; | |
| 71 | 74 | return layout( |
| 72 | 75 | `<section class="compose" aria-labelledby="compose-title"> |
| 73 | 76 | <h2 id="compose-title">Launch a listing</h2> |
| @@ -76,7 +79,8 @@ export function home(input: { | ||
| 76 | 79 | </section> |
| 77 | 80 | <section class="launched" aria-labelledby="launched-title"> |
| 78 | 81 | <h2 id="launched-title">Launched</h2> |
| 79 | <div id="listings" data-live="listing.launched listing.changed listing.removed">${list(listings)}</div> | |
| 82 | ${finder(listings, wanted)} | |
| 83 | <div id="listings" data-live="listing.launched listing.changed listing.removed">${list(listings, wanted)}</div> | |
| 80 | 84 | </section> |
| 81 | 85 | ${toast(notice)}`, |
| 82 | 86 | who, |
| @@ -167,7 +171,39 @@ export function form(who: Who, draft: Draft, editing?: Editing): string { | ||
| 167 | 171 | </form>`; |
| 168 | 172 | } |
| 169 | 173 | |
| 170 | function list(listings: readonly ListingSummary[]): string { | |
| 174 | const FILTER_LABELS: Readonly<Record<Filter, string>> = { | |
| 175 | all: "All", | |
| 176 | active: STATUS_LABELS.active, | |
| 177 | under_contract: STATUS_LABELS.under_contract, | |
| 178 | sold: STATUS_LABELS.sold, | |
| 179 | "needs-approval": "Needs approval", | |
| 180 | }; | |
| 181 | ||
| 182 | /** The way to the one being talked about: a link per filter with its count, and a search by address. */ | |
| 183 | function finder(listings: readonly ListingSummary[], wanted: Find): string { | |
| 184 | const n = counts(listings, wanted.q); | |
| 185 | const href = (f: Filter): string => { | |
| 186 | const q = new URLSearchParams(); | |
| 187 | if (f !== "all") q.set("status", f); | |
| 188 | if (wanted.q !== "") q.set("q", wanted.q); | |
| 189 | return q.size === 0 ? "/" : `/?${q}`; | |
| 190 | }; | |
| 191 | return `<nav class="filters" aria-label="Filter the listings"><ul class="row">${FILTERS.map( | |
| 192 | (f) => | |
| 193 | `<li><a class="filter" href="${escape(href(f))}"${f === wanted.status ? ' aria-current="true"' : ""}>${FILTER_LABELS[f]} <span class="muted">${n[f]}</span></a></li>`, | |
| 194 | ).join("")}</ul></nav> | |
| 195 | <form class="row search" method="get" action="/" role="search"> | |
| 196 | ${wanted.status === "all" ? "" : `<input type="hidden" name="status" value="${escape(wanted.status)}">`} | |
| 197 | <label class="sr" for="q">Search by address</label> | |
| 198 | <input class="input" id="q" type="search" name="q" value="${escape(wanted.q)}" maxlength="${Q_MAX}" placeholder="Search by address"> | |
| 199 | <button class="button button-quiet">Search</button> | |
| 200 | </form>`; | |
| 201 | } | |
| 202 | ||
| 203 | function list(listings: readonly ListingSummary[], wanted: Find): string { | |
| 204 | listings = find(listings, wanted); | |
| 205 | if (listings.length === 0 && (wanted.q !== "" || wanted.status !== "all")) | |
| 206 | return `<p class="empty">No listing matches. <a href="/">Clear the search</a></p>`; | |
| 171 | 207 | if (listings.length === 0) |
| 172 | 208 | return `<p class="empty">Nothing launched yet. Fill in the form above.</p>`; |
| 173 | 209 | return `<ul class="cards">${listings |
src/routes.ts +13 −3
| @@ -43,6 +43,7 @@ import { | ||
| 43 | 43 | } from "./actions.ts"; |
| 44 | 44 | import { kit, kitFilename } from "./kit.ts"; |
| 45 | 45 | import { KINDS, STATUSES, TONES } from "./generate.ts"; |
| 46 | import { FILTERS, parseFind, Q_MAX } from "./find.ts"; | |
| 46 | 47 | import { ASSET_MAX, FEATURES_MAX, FEATURES_MIN, PALETTES } from "./listing.ts"; |
| 47 | 48 | import { commit, env, forgeUrl, startedAt, version } from "./env.ts"; |
| 48 | 49 | import { |
| @@ -408,7 +409,9 @@ export const routes: readonly Route[] = [ | ||
| 408 | 409 | path: "/", |
| 409 | 410 | handle: (c) => { |
| 410 | 411 | const { notice, clear } = readNotice(c.request); |
| 411 | const listings = listListings(c.db, c.who); | |
| 412 | const wanted = parseFind(c.url.searchParams.get("status"), c.url.searchParams.get("q")); | |
| 413 | // The whole list for the words typed: the counts are of it, the filter narrows it. | |
| 414 | const listings = listListings(c.db, c.who, { q: wanted.q }); | |
| 412 | 415 | // Start from a listing the person chose, or from their own last brand. |
| 413 | 416 | const from = whole(c.url.searchParams.get("from")); |
| 414 | 417 | const chosen = |
| @@ -427,6 +430,7 @@ export const routes: readonly Route[] = [ | ||
| 427 | 430 | home({ |
| 428 | 431 | who: c.who, |
| 429 | 432 | listings: listings.ok ? listings.value : [], |
| 433 | find: wanted, | |
| 430 | 434 | draft, |
| 431 | 435 | notice, |
| 432 | 436 | }), |
| @@ -812,11 +816,17 @@ export const routes: readonly Route[] = [ | ||
| 812 | 816 | answer( |
| 813 | 817 | listListings(c.db, c.who, { |
| 814 | 818 | limit: Number(c.url.searchParams.get("limit") ?? 100), |
| 819 | status: c.url.searchParams.get("status"), | |
| 820 | q: c.url.searchParams.get("q"), | |
| 815 | 821 | }), |
| 816 | 822 | ), |
| 817 | 823 | api: { |
| 818 | summary: "The listings, newest first, each with how many phrases the Fair Housing check flags.", | |
| 819 | query: { limit: LIMIT }, | |
| 824 | summary: "The listings, newest first, each with how many phrases the Fair Housing check flags. Filter by status and search the address.", | |
| 825 | query: { | |
| 826 | limit: LIMIT, | |
| 827 | status: { enum: [...FILTERS], description: "Only listings that are active, under_contract, sold or needs-approval; anything else is all." }, | |
| 828 | q: { type: "string", maxLength: Q_MAX, description: "Only listings whose address contains this, any case." }, | |
| 829 | }, | |
| 820 | 830 | answer: list(LISTING_SUMMARY), |
| 821 | 831 | }, |
| 822 | 832 | }, |
src/tty.test.ts +20
| @@ -225,6 +225,26 @@ test("the terminal shows a social post's length, and a short post over X needs a | ||
| 225 | 225 | expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x"]); |
| 226 | 226 | }); |
| 227 | 227 | |
| 228 | test("f cycles the listings filter and / searches by address, as the page does", () => { | |
| 229 | const { db } = fresh(); | |
| 230 | const tty = session(db, me); | |
| 231 | expect(plain(tty.screen())).toContain("Showing all"); | |
| 232 | tty.press("f"); // active | |
| 233 | expect(plain(tty.screen())).toContain("Showing active"); | |
| 234 | tty.press("f"); // under contract: the sample is not | |
| 235 | expect(plain(tty.screen())).toContain("No listing matches."); | |
| 236 | while (!plain(tty.screen()).includes("Showing all")) tty.press("f"); | |
| 237 | tty.press("/"); | |
| 238 | for (const key of "nowhere-at-all") tty.press(key); | |
| 239 | tty.press("\r"); | |
| 240 | expect(plain(tty.screen())).toContain('matching "nowhere-at-all"'); | |
| 241 | expect(plain(tty.screen())).toContain("No listing matches."); | |
| 242 | tty.press("/"); | |
| 243 | for (let i = 0; i < 20; i++) tty.press("\x7f"); | |
| 244 | tty.press("\r"); | |
| 245 | expect(plain(tty.screen())).toContain("▸"); | |
| 246 | }); | |
| 247 | ||
| 228 | 248 | test("the listing view shows the property page URL, and that it is not public until the MLS text is approved", () => { |
| 229 | 249 | const { db, id } = fresh(); |
| 230 | 250 | const tty = session(db, me); |
src/tty.ts +21 −5
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | 19 | import type { Database } from "bun:sqlite"; |
| 20 | import { FILTERS, parseFind, type Find } from "./find.ts"; | |
| 20 | 21 | import { |
| 21 | 22 | addNote, |
| 22 | 23 | approveAsset, |
| @@ -93,8 +94,10 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 93 | 94 | let kitting = false; |
| 94 | 95 | let cursor = 0; |
| 95 | 96 | /** What the one-line input is for: a new note, or an asset's new words. */ |
| 96 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | undefined; | |
| 97 | let typing: { for: "note" } | { for: "asset"; kind: Kind } | { for: "open" } | { for: "status" } | { for: "price" } | { for: "search" } | undefined; | |
| 97 | 98 | let typed = ""; |
| 99 | /** The filter and the words the listings view is narrowed to. */ | |
| 100 | let found: Find = { status: "all", q: "" }; | |
| 98 | 101 | let said = ""; |
| 99 | 102 | /** The short post over X that `a` was pressed on once: a second `a` approves it knowing so. */ |
| 100 | 103 | let longAsked: string | undefined; |
| @@ -104,7 +107,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 104 | 107 | return listed.ok ? scrub(listed.value) : []; |
| 105 | 108 | }; |
| 106 | 109 | const listings = () => { |
| 107 | const listed = listListings(db, me); | |
| 110 | const listed = listListings(db, me, found); | |
| 108 | 111 | return listed.ok ? scrub(listed.value) : []; |
| 109 | 112 | }; |
| 110 | 113 | const open = () => { |
| @@ -163,7 +166,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 163 | 166 | : "j/↓↑ asset · p preview email · k launch kit · esc back · q quit"; |
| 164 | 167 | } else if (view === "listings") { |
| 165 | 168 | const rows = listings(); |
| 166 | if (rows.length === 0) lines.push(dim("No listings yet. Launch one on the page.")); | |
| 169 | lines.push(dim(`Showing ${found.status.replace(/[-_]/g, " ")}${found.q === "" ? "" : ` matching "${found.q}"`}`)); | |
| 170 | if (rows.length === 0) | |
| 171 | lines.push(dim(found.status === "all" && found.q === "" ? "No listings yet. Launch one on the page." : "No listing matches. Press f for All and / then enter to clear the search.")); | |
| 167 | 172 | rows.forEach((l, i) => { |
| 168 | 173 | const done = l.approvals.filter((a) => a.approved).length; |
| 169 | 174 | const ready = approvalLabel(done, l.approvals.length); |
| @@ -173,7 +178,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 173 | 178 | `${i === cursor ? "▸" : " "} ${i === cursor ? bold(text) : text} ${done === l.approvals.length ? green(ready) : dim(ready)} ${flag} ${dim(l.author_name)}`, |
| 174 | 179 | ); |
| 175 | 180 | }); |
| 176 | help = "j/k move · enter open · x remove · tab notes · q quit"; | |
| 181 | help = "j/k move · enter open · f filter · / search · x remove · tab notes · q quit"; | |
| 177 | 182 | } else { |
| 178 | 183 | const rows = notes(); |
| 179 | 184 | if (rows.length === 0) |
| @@ -191,7 +196,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 191 | 196 | } |
| 192 | 197 | lines.push(""); |
| 193 | 198 | if (typing !== undefined) { |
| 194 | const label = typing.for === "note" ? "New note" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: active, under contract, or sold with an optional price, like sold 752000 (enter saves, esc drops)" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`; | |
| 199 | const label = typing.for === "note" ? "New note" : typing.for === "search" ? "Search by address (enter searches, empty clears, esc drops)" : typing.for === "price" ? "Price in whole dollars, like 849000; a lower price adds a price improvement post and email (enter saves, esc drops)" : typing.for === "status" ? "Status: active, under contract, or sold with an optional price, like sold 752000 (enter saves, esc drops)" : typing.for === "open" ? "Open house: date start-end, like 2027-03-09 13:00-15:00; separate two with ; (empty clears, enter saves, esc drops)" : `Edit (\\n breaks a line, enter saves, esc drops)`; | |
| 195 | 200 | // The tail of what is typed, so the cursor stays on the screen. |
| 196 | 201 | const room = Math.max(20, size.columns - 4); |
| 197 | 202 | lines.push(dim(label), `${typed.length > room ? `…${typed.slice(-room + 1)}` : typed}${esc}7m ${esc}0m`); |
| @@ -207,6 +212,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 207 | 212 | const added = addNote(db, me, { body: typed }); |
| 208 | 213 | if (!added.ok) said = added.message; |
| 209 | 214 | cursor = 0; |
| 215 | } else if (typing.for === "search") { | |
| 216 | found = parseFind(found.status, typed); | |
| 217 | cursor = 0; | |
| 210 | 218 | } else if (typing.for === "open") { |
| 211 | 219 | const done = updateFacts(db, me, { id: reading!, fields: { open_houses: openFromLine(typed) } }); |
| 212 | 220 | said = done.ok ? "Open house saved. Every asset is written again: read it through." : done.message; |
| @@ -337,6 +345,14 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 } | ||
| 337 | 345 | reading = listing.id; |
| 338 | 346 | cursor = 0; |
| 339 | 347 | } |
| 348 | if (key === "f") { | |
| 349 | found = { ...found, status: FILTERS[(FILTERS.indexOf(found.status) + 1) % FILTERS.length]! }; | |
| 350 | cursor = 0; | |
| 351 | } | |
| 352 | if (key === "/") { | |
| 353 | typing = { for: "search" }; | |
| 354 | typed = found.q; | |
| 355 | } | |
| 340 | 356 | if (key === "x") { |
| 341 | 357 | if (listing === undefined) said = "Nothing to remove."; |
| 342 | 358 | else if (!canRemoveListing(me, listing)) |
static/app.css +29
| @@ -973,6 +973,35 @@ main:focus { | ||
| 973 | 973 | flex-wrap: wrap; |
| 974 | 974 | } |
| 975 | 975 | |
| 976 | /* Finding a listing: a link per filter, a search by address. */ | |
| 977 | .filters ul { | |
| 978 | list-style: none; | |
| 979 | margin: 0 0 var(); | |
| 980 | padding: 0; | |
| 981 | flex-wrap: wrap; | |
| 982 | } | |
| 983 | .filter { | |
| 984 | display: inline-flex; | |
| 985 | align-items: center; | |
| 986 | gap: var(); | |
| 987 | min-height: var(); | |
| 988 | padding: 0 var(); | |
| 989 | border: 1px solid var(); | |
| 990 | border-radius: var(); | |
| 991 | text-decoration: none; | |
| 992 | } | |
| 993 | .filter[aria-current] { | |
| 994 | font-weight: 600; | |
| 995 | border-color: var(); | |
| 996 | } | |
| 997 | .search { | |
| 998 | margin-bottom: var(); | |
| 999 | } | |
| 1000 | .search .input { | |
| 1001 | flex: 1; | |
| 1002 | min-width: 0; | |
| 1003 | } | |
| 1004 | ||
| 976 | 1005 | /* The property page buyers read, and the links that lead to it. */ |
| 977 | 1006 | .property-link { |
| 978 | 1007 | flex-wrap: wrap; |
High: the
.search .input { flex: 1; min-width: 0;block is never closed (the merge with #50 dropped the}). The following.property-linkrule is parsed as part of it and its styling is lost. Add}aftermin-width: 0;.