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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/48-find-a-listing-on-the-home-list-filter-b → main 5 commits fork 1 thread · 1 unresolved

  • Merged. mason/48-find-a-listing-on-the-home-list-filter-b was merged into main as b211767.

Review

Sign in to review this change.
13 files changed +310 −27
README.md +6
@@ -54,6 +54,9 @@ to make a listing of your own.
5454 and needing its own approval. A raise or no change adds nothing; raising
5555 the price back to the original, or above it, takes them away. The same
5656 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.
5760 9. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
5861 the photo, price, facts, description and your name. Print it or save it
5962 as a PDF.
@@ -84,6 +87,9 @@ list cannot drift from the app.
8487 or make another the hero (24 MB of new files per save; the flyer and the email need approving again).
8588 Over the JSON API, `PATCH` `{ photos: { order, remove } }` reorders and
8689 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.
8793 Your brand (name, brokerage, license number, phone, email, headshot,
8894 colour) is asked once and carried to your next listing.
8995 2. **Pick a tone** — *classic* (measured), *warm* (inviting) or *bold*
contract/openapi.json +25 −2
@@ -298,7 +298,7 @@
298298 },
299299 "/api/listings": {
300300 "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.",
302302 "operationId": "getApiListings",
303303 "parameters": [
304304 {
@@ -310,11 +310,34 @@
310310 "maximum": 500,
311311 "default": 100
312312 }
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 }
313336 }
314337 ],
315338 "responses": {
316339 "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.",
318341 "content": {
319342 "application/json": {
320343 "schema": {
src/actions.ts +19 −11
@@ -30,6 +30,7 @@ import {
3030 type ListingInput,
3131 type ListingRow,
3232 } from "./listing.ts";
33 import { matchesStatus, matchesText, parseFind } from "./find.ts";
3334 import { kit } from "./kit.ts";
3435 import { emit } from "./live.ts";
3536 import { meantForX, overXNote, postLength } from "./postlength.ts";
@@ -434,8 +435,9 @@ export type ListingSummary = Listing & {
434435 export function listListings(
435436 db: Database,
436437 who: Who,
437 input: { limit?: number } = {},
438 input: { limit?: number; status?: unknown; q?: unknown } = {},
438439 ): Result<ListingSummary[]> {
440 const wanted = parseFind(input.status, input.q);
439441 const limit = Math.min(
440442 Math.max(Math.trunc(input.limit ?? 100) || 100, 1),
441443 500,
@@ -443,30 +445,36 @@ export function listListings(
443445 const may = permit(who, "listing", "list");
444446 if (may === "no")
445447 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;
446450 const rows =
447451 may === "yes"
448452 ? db
449453 .query<ListingRow, [number]>(
450454 "SELECT * FROM listing ORDER BY id DESC LIMIT ?1",
451455 )
452 .all(limit)
456 .all(reach)
453457 : db
454458 .query<ListingRow, [number, string]>(
455459 `SELECT * FROM listing WHERE ${may.where.map((c) => `${c} = ?2`).join(" OR ")} ORDER BY id DESC LIMIT ?1`,
456460 )
457 .all(limit, who.role === "anon" ? "" : who.id);
461 .all(reach, who.role === "anon" ? "" : who.id);
458462 const bodies = db.query<{ kind: string; body: string; approved_at: string | null }, [number]>(
459463 "SELECT kind, body, approved_at FROM asset WHERE listing_id = ?1 ORDER BY rowid",
460464 );
461465 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),
470478 );
471479 }
472480
src/design.test.ts +19
@@ -150,3 +150,22 @@ test("the phone rules are in the stylesheet and print is untouched by them", asy
150150 expect(block).toContain(rule);
151151 expect(block).not.toContain("@media print");
152152 });
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
919919 const outcomes = db.query<{ outcome: string }, []>("SELECT outcome FROM audit WHERE outcome LIKE 'approved%' ORDER BY id").all();
920920 expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x", "approved"]);
921921 });
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="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"');
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 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { FILTERS, Q_MAX } from "./find.ts";
1011 import {
1112 addNote,
1213 approveAsset,
@@ -95,13 +96,17 @@ export const tools: readonly Tool[] = [
9596 {
9697 name: "list_listings",
9798 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.",
99100 inputSchema: {
100101 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 },
102107 },
103108 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 }),
105110 },
106111 {
107112 name: "get_listing",
src/pages/home.ts +39 −3
@@ -5,8 +5,9 @@
55 * placeholders, so an empty form still shows what goes where.
66 */
77
8 import { count, money, TONES, type Tone } from "../generate.ts";
8 import { count, money, STATUS_LABELS, TONES, type Tone } from "../generate.ts";
99 import type { ListingSummary } from "../actions.ts";
10 import { counts, Q_MAX, find, FILTERS, type Filter, type Find } from "../find.ts";
1011 import { escape } from "../http.ts";
1112 import {
1213 FEATURES_MAX,
@@ -64,10 +65,12 @@ export function draftOf(listing: Listing, brandOnly: boolean): Draft {
6465 export function home(input: {
6566 who: Who;
6667 listings: readonly ListingSummary[];
68 find?: Find;
6769 draft: Draft;
6870 notice?: string | undefined;
6971 }): string {
7072 const { who, listings, draft, notice } = input;
73 const wanted = input.find ?? { status: "all", q: "" };
7174 return layout(
7275 `<section class="compose" aria-labelledby="compose-title">
7376 <h2 id="compose-title">Launch a listing</h2>
@@ -76,7 +79,8 @@ export function home(input: {
7679 </section>
7780 <section class="launched" aria-labelledby="launched-title">
7881 <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>
8084 </section>
8185 ${toast(notice)}`,
8286 who,
@@ -167,7 +171,39 @@ export function form(who: Who, draft: Draft, editing?: Editing): string {
167171 </form>`;
168172 }
169173
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>`;
171207 if (listings.length === 0)
172208 return `<p class="empty">Nothing launched yet. Fill in the form above.</p>`;
173209 return `<ul class="cards">${listings
src/routes.ts +13 −3
@@ -43,6 +43,7 @@ import {
4343 } from "./actions.ts";
4444 import { kit, kitFilename } from "./kit.ts";
4545 import { KINDS, STATUSES, TONES } from "./generate.ts";
46 import { FILTERS, parseFind, Q_MAX } from "./find.ts";
4647 import { ASSET_MAX, FEATURES_MAX, FEATURES_MIN, PALETTES } from "./listing.ts";
4748 import { commit, env, forgeUrl, startedAt, version } from "./env.ts";
4849 import {
@@ -408,7 +409,9 @@ export const routes: readonly Route[] = [
408409 path: "/",
409410 handle: (c) => {
410411 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 });
412415 // Start from a listing the person chose, or from their own last brand.
413416 const from = whole(c.url.searchParams.get("from"));
414417 const chosen =
@@ -427,6 +430,7 @@ export const routes: readonly Route[] = [
427430 home({
428431 who: c.who,
429432 listings: listings.ok ? listings.value : [],
433 find: wanted,
430434 draft,
431435 notice,
432436 }),
@@ -812,11 +816,17 @@ export const routes: readonly Route[] = [
812816 answer(
813817 listListings(c.db, c.who, {
814818 limit: Number(c.url.searchParams.get("limit") ?? 100),
819 status: c.url.searchParams.get("status"),
820 q: c.url.searchParams.get("q"),
815821 }),
816822 ),
817823 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 },
820830 answer: list(LISTING_SUMMARY),
821831 },
822832 },
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
225225 expect(outcomes.map((r) => r.outcome)).toEqual(["approved_too_long_for_x"]);
226226 });
227227
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
228248 test("the listing view shows the property page URL, and that it is not public until the MLS text is approved", () => {
229249 const { db, id } = fresh();
230250 const tty = session(db, me);
src/tty.ts +21 −5
@@ -17,6 +17,7 @@
1717 */
1818
1919 import type { Database } from "bun:sqlite";
20 import { FILTERS, parseFind, type Find } from "./find.ts";
2021 import {
2122 addNote,
2223 approveAsset,
@@ -93,8 +94,10 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
9394 let kitting = false;
9495 let cursor = 0;
9596 /** 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;
9798 let typed = "";
99 /** The filter and the words the listings view is narrowed to. */
100 let found: Find = { status: "all", q: "" };
98101 let said = "";
99102 /** The short post over X that `a` was pressed on once: a second `a` approves it knowing so. */
100103 let longAsked: string | undefined;
@@ -104,7 +107,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
104107 return listed.ok ? scrub(listed.value) : [];
105108 };
106109 const listings = () => {
107 const listed = listListings(db, me);
110 const listed = listListings(db, me, found);
108111 return listed.ok ? scrub(listed.value) : [];
109112 };
110113 const open = () => {
@@ -163,7 +166,9 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
163166 : "j/↓↑ asset · p preview email · k launch kit · esc back · q quit";
164167 } else if (view === "listings") {
165168 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."));
167172 rows.forEach((l, i) => {
168173 const done = l.approvals.filter((a) => a.approved).length;
169174 const ready = approvalLabel(done, l.approvals.length);
@@ -173,7 +178,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
173178 `${i === cursor ? "▸" : " "} ${i === cursor ? bold(text) : text} ${done === l.approvals.length ? green(ready) : dim(ready)} ${flag} ${dim(l.author_name)}`,
174179 );
175180 });
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";
177182 } else {
178183 const rows = notes();
179184 if (rows.length === 0)
@@ -191,7 +196,7 @@ export function session(db: Database, me: Who, size = { rows: 40, columns: 100 }
191196 }
192197 lines.push("");
193198 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)`;
195200 // The tail of what is typed, so the cursor stays on the screen.
196201 const room = Math.max(20, size.columns - 4);
197202 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 }
207212 const added = addNote(db, me, { body: typed });
208213 if (!added.ok) said = added.message;
209214 cursor = 0;
215 } else if (typing.for === "search") {
216 found = parseFind(found.status, typed);
217 cursor = 0;
210218 } else if (typing.for === "open") {
211219 const done = updateFacts(db, me, { id: reading!, fields: { open_houses: openFromLine(typed) } });
212220 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 }
337345 reading = listing.id;
338346 cursor = 0;
339347 }
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 }
340356 if (key === "x") {
341357 if (listing === undefined) said = "Nothing to remove.";
342358 else if (!canRemoveListing(me, listing))
static/app.css +29
@@ -973,6 +973,35 @@ main:focus {
973973 flex-wrap: wrap;
974974 }
975975
976 /* Finding a listing: a link per filter, a search by address. */
977 .filters ul {
978 list-style: none;
979 margin: 0 0 var(--space-3);
980 padding: 0;
981 flex-wrap: wrap;
982 }
983 .filter {
984 display: inline-flex;
985 align-items: center;
986 gap: var(--space-1);
987 min-height: var(--size-tap);
988 padding: 0 var(--space-3);
989 border: 1px solid var(--color-line);
990 border-radius: var(--radius-md);
991 text-decoration: none;
992 }
993 .filter[aria-current] {
994 font-weight: 600;
995 border-color: var(--color-accent);
996 }
997 .search {
998 margin-bottom: var(--space-4);
999 }
1000 .search .input {
1001 flex: 1;
1002 min-width: 0;

High: the .search .input { flex: 1; min-width: 0; block is never closed (the merge with #50 dropped the }). The following .property-link rule is parsed as part of it and its styling is lost. Add } after min-width: 0;.

1003 }
1004
9761005 /* The property page buyers read, and the links that lead to it. */
9771006 .property-link {
9781007 flex-wrap: wrap;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs