Public: anyone can read this; the crew can change it.

Three sample listings for the meeting, one that shows the Fair Housing check catching something #12

Merged opened this

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

Merged

mason/listing-launch:mason/5-three-sample-listings-for-the-meeting-on → main 1 commit fork

  • Merged. mason/5-three-sample-listings-for-the-meeting-on was merged into main as 44feced.

Review

Sign in to review this change.
10 files changed +210 −18
README.md +1 −1
@@ -35,7 +35,7 @@ One address in, a listing launched: the MLS description, three social posts, a f
3535 Print it, or save it as a PDF. "Rewrite all" writes every asset again in
3636 another tone (your edits are replaced, so it asks with a tooltip first).
3737
38 A sample listing (412 Alder Lane) is there from the start so the screen is
38 Three sample listings are there from the start so the screen is
3939 never empty; "Start a new one from this" copies any listing into the form.
4040
4141 **What is real.** Your listings, edits and photos are saved (SQLite and
src/listings.test.ts +3 −1
@@ -61,12 +61,14 @@ test("the first screen is full: the form, and the sample listing", async () => {
6161 const page = await (await fetch(base + "/")).text();
6262 expect(page).toContain('name="address"');
6363 expect(page).toContain("412 Alder Lane");
64 expect(page).toContain("88 Meridian Street");
65 expect(page).toContain("27 Birchwood Court");
6466 expect(page).toContain("Fair Housing: clear");
6567 expect(page).toContain("/static/sample-front.svg?v=");
6668 // Planted once: a second boot on the same data plants nothing.
6769 const again = openDb(dir);
6870 seed(again);
69 expect(again.query("SELECT count(*) AS n FROM listing").get()).toEqual({ n: 1 });
71 expect(again.query("SELECT count(*) AS n FROM listing").get()).toEqual({ n: 3 });
7072 });
7173
7274 test("Launch saves the listing with its photos, and it reopens at /listings/:id", async () => {
src/sample.test.ts added +47
@@ -0,0 +1,47 @@
1 /** The sample listings: three, planted once each, one of them flagged. */
2
3 import { expect, test } from "bun:test";
4 import { mkdtempSync } from "node:fs";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { openDb } from "./db.ts";
8 import { check } from "./fairhousing.ts";
9 import { seed } from "./sample.ts";
10
11 const fresh = () => openDb(mkdtempSync(join(tmpdir(), "sample-")));
12 const count = (db: ReturnType<typeof fresh>) =>
13 (db.query("SELECT count(*) AS n FROM listing").get() as { n: number }).n;
14
15 test("a fresh database has three samples, and seeding twice keeps three", () => {
16 const db = fresh();
17 seed(db);
18 expect(count(db)).toBe(3);
19 seed(db);
20 expect(count(db)).toBe(3);
21 });
22
23 test("a database that only had Alder Lane gets the other two, not a second Alder Lane", () => {
24 const db = fresh();
25 seed(db);
26 db.run("DELETE FROM listing WHERE address NOT LIKE '412 Alder%'");
27 db.run("DELETE FROM seeded WHERE name != 'sample'");
28 expect(count(db)).toBe(1);
29 seed(db);
30 expect(count(db)).toBe(3);
31 expect(db.query("SELECT count(*) AS n FROM listing WHERE address LIKE '412 Alder%'").get()).toEqual({ n: 1 });
32 });
33
34 test("the house opens amber, the condo and Alder Lane green", () => {
35 const db = fresh();
36 seed(db);
37 const flags = (like: string) =>
38 (db
39 .query<{ body: string }, [string]>(
40 "SELECT body FROM asset WHERE listing_id = (SELECT id FROM listing WHERE address LIKE ?1)",
41 )
42 .all(like)
43 .reduce((n, a) => n + check(a.body).findings.length, 0));
44 expect(flags("27 Birchwood%")).toBeGreaterThan(0);
45 expect(flags("88 Meridian%")).toBe(0);
46 expect(flags("412 Alder%")).toBe(0);
47 });
src/sample.ts +91 −16
@@ -1,8 +1,8 @@
11 /**
2 * The sample listing, planted once in a fresh database so the first screen
3 * is full. It belongs to no one who can sign in (an admin can still edit or
4 * remove it), its pictures are illustrations in `static/`, and once removed
5 * it stays removed. Its facts double as the form's placeholders.
2 * The sample listings, each planted once in a fresh database so the first screen
3 * is full. They belong to no one who can sign in (an admin can still edit or
4 * remove them), their pictures are illustrations in `static/`, and once removed
5 * one removed stays removed. Alder Lane's facts double as the form's placeholders.
66 */
77
88 import type { Database } from "bun:sqlite";
@@ -38,19 +38,94 @@ export const SAMPLE: ListingInput = {
3838 export const SAMPLE_PHOTOS = ["sample-front.svg", "sample-garden.svg"];
3939 export const SAMPLE_HEADSHOT = "sample-agent.svg";
4040
41 /** Plant the sample, unless it has been planted before. */
41 /** A downtown condo in the bold tone: clean, so its check is green. */
42 export const SAMPLE_CONDO: ListingInput = {
43 address: "88 Meridian Street, Unit 1204, Seattle, WA 98101",
44 beds: 2,
45 baths: 2,
46 sqft: 1185,
47 lot: "Condo, no lot",
48 year: 2016,
49 price: 745000,
50 features: [
51 "Corner unit on the 12th floor with floor-to-ceiling windows",
52 "Open kitchen with an island, gas cooktop and wine fridge",
53 "Primary suite with a soaking tub and a walk-in shower",
54 "In-unit washer and dryer, one deeded parking space",
55 "Rooftop deck with a fire table and a view of the Sound",
56 "Fitness room and 24-hour concierge in the building",
57 "Two blocks to the light rail and the Pike Place Market",
58 ],
59 tone: "bold",
60 palette: "navy",
61 agent_name: "Jordan Whitfield",
62 brokerage: "Cascade Row Properties",
63 license: "WA 700123456",
64 agent_phone: "(206) 555-0178",
65 agent_email: "[email protected]",
66 };
67
68 /**
69 * A family-sized house in the classic tone whose feature lines say things the
70 * Fair Housing check flags, so its listing opens amber: the one to show a room.
71 */
72 export const SAMPLE_HOUSE: ListingInput = {
73 address: "27 Birchwood Court, Boise, ID 83702",
74 beds: 4,
75 baths: 3,
76 sqft: 2860,
77 lot: "0.31 acre",
78 year: 1994,
79 price: 559000,
80 features: [
81 "Family-friendly street with sidewalks and mature maples",
82 "Walking distance to church and the neighborhood library",
83 "Great for young couples starting out",
84 "Open great room with a gas fireplace and vaulted ceiling",
85 "Fenced back yard with a swing set and a covered patio",
86 "Three-car garage and a bonus room over it",
87 "New roof and a high-efficiency furnace in 2021",
88 ],
89 tone: "classic",
90 palette: "burgundy",
91 agent_name: "Casey Lindqvist",
92 brokerage: "Gem State Homes",
93 license: "ID SP98765432",
94 agent_phone: "(208) 555-0163",
95 agent_email: "[email protected]",
96 };
97
98 interface Sample {
99 /** The `seeded` row: once present, never planted again. */
100 readonly name: string;
101 readonly listing: ListingInput;
102 readonly photos: readonly string[];
103 readonly headshot: string;
104 }
105
106 const SAMPLES: readonly Sample[] = [
107 { name: "sample", listing: SAMPLE, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT },
108 {
109 name: "sample-condo",
110 listing: SAMPLE_CONDO,
111 photos: ["sample-condo-front.svg", "sample-condo-living.svg"],
112 headshot: "sample-agent-2.svg",
113 },
114 {
115 name: "sample-house",
116 listing: SAMPLE_HOUSE,
117 photos: ["sample-house-front.svg", "sample-house-yard.svg"],
118 headshot: "sample-agent-3.svg",
119 },
120 ];
121
122 /** Plant each sample that has not been planted before; a removed one stays removed. */
42123 export function seed(db: Database): void {
43124 db.transaction(() => {
44 const done = db
45 .query("INSERT OR IGNORE INTO seeded (name) VALUES ('sample')")
46 .run();
47 if (done.changes === 0) return;
48 insertListing(
49 db,
50 { id: "sample", name: "Sample listing" },
51 SAMPLE,
52 SAMPLE_PHOTOS,
53 SAMPLE_HEADSHOT,
54 );
125 for (const s of SAMPLES) {
126 const done = db.query("INSERT OR IGNORE INTO seeded (name) VALUES (?1)").run(s.name);
127 if (done.changes === 0) continue;
128 insertListing(db, { id: "sample", name: "Sample listing" }, s.listing, s.photos, s.headshot);
129 }
55130 })();
56131 }
static/sample-agent-2.svg added +7
@@ -0,0 +1,7 @@
1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200" role="img" aria-label="Illustration: a sample agent">
2 <rect width="200" height="200" fill="#dde3ee"/>
3 <circle cx="100" cy="82" r="38" fill="#e0b894"/>
4 <path d="M62 84 q0 -52 40 -52 q38 0 38 52 q-10 -30 -38 -30 q-30 0 -40 30z" fill="#8a8f98"/>
5 <path d="M30 200 q8 -70 70 -70 q62 0 70 70z" fill="#1f2a44"/>
6 <path d="M88 130 l12 22 l12 -22z" fill="#f7f4ee"/>
7 </svg>
static/sample-agent-3.svg added +7
@@ -0,0 +1,7 @@
1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200" role="img" aria-label="Illustration: a sample agent">
2 <rect width="200" height="200" fill="#e8dfe0"/>
3 <circle cx="100" cy="82" r="38" fill="#8d6248"/>
4 <path d="M60 80 q2 -48 42 -48 q36 0 38 48 q-8 -22 -40 -22 q-30 0 -40 22z" fill="#1d1a18"/>
5 <path d="M30 200 q8 -70 70 -70 q62 0 70 70z" fill="#6b2f3a"/>
6 <path d="M88 130 l12 22 l12 -22z" fill="#f7f4ee"/>
7 </svg>
static/sample-condo-front.svg added +13
@@ -0,0 +1,13 @@
1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800" role="img" aria-label="Illustration: a downtown condo tower at night">
2 <defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1f2a44"/><stop offset="1" stop-color="#6f7fa6"/></linearGradient></defs>
3 <rect width="1200" height="800" fill="url(#sky)"/>
4 <circle cx="980" cy="150" r="46" fill="#eef0e6"/>
5 <g fill="#2f383e" opacity=".8"><rect x="80" y="360" width="140" height="440"/><rect x="230" y="300" width="110" height="500"/><rect x="900" y="330" width="130" height="470"/><rect x="1040" y="420" width="120" height="380"/></g>
6 <rect x="400" y="120" width="400" height="680" fill="#e9dcc4"/>
7 <rect x="400" y="120" width="400" height="24" fill="#3e4a52"/>
8 <g fill="#f6d58e"><rect x="430" y="170" width="70" height="60"/><rect x="530" y="170" width="70" height="60"/><rect x="630" y="170" width="70" height="60"/><rect x="730" y="170" width="40" height="60"/><rect x="430" y="270" width="70" height="60"/><rect x="630" y="270" width="70" height="60"/><rect x="730" y="270" width="40" height="60"/><rect x="430" y="370" width="70" height="60"/><rect x="530" y="370" width="70" height="60"/><rect x="730" y="370" width="40" height="60"/><rect x="530" y="470" width="70" height="60"/><rect x="630" y="470" width="70" height="60"/></g>
9 <g fill="#9fb3c8"><rect x="530" y="270" width="70" height="60"/><rect x="630" y="370" width="70" height="60"/><rect x="430" y="470" width="70" height="60"/><rect x="730" y="470" width="40" height="60"/></g>
10 <rect x="540" y="620" width="120" height="180" fill="#2f383e"/>
11 <rect x="550" y="630" width="45" height="170" fill="#9fb3c8"/><rect x="605" y="630" width="45" height="170" fill="#9fb3c8"/>
12 <rect y="760" width="1200" height="40" fill="#3e4a52"/>
13 </svg>
static/sample-condo-living.svg added +11
@@ -0,0 +1,11 @@
1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800" role="img" aria-label="Illustration: a condo living room with a city view">
2 <rect width="1200" height="800" fill="#eef0e6"/>
3 <rect x="160" y="80" width="880" height="470" fill="#9fb3c8"/>
4 <g fill="#3e4a52"><rect x="220" y="330" width="90" height="220"/><rect x="330" y="260" width="110" height="290"/><rect x="470" y="360" width="80" height="190"/><rect x="590" y="220" width="120" height="330"/><rect x="740" y="320" width="100" height="230"/><rect x="870" y="380" width="110" height="170"/></g>
5 <g stroke="#e9dcc4" stroke-width="12"><path d="M600 80v470M160 315h880"/></g>
6 <rect y="550" width="1200" height="250" fill="#cdbb9b"/>
7 <rect x="260" y="500" width="520" height="130" rx="30" fill="#2d5a43"/>
8 <rect x="240" y="470" width="60" height="160" rx="24" fill="#2d5a43"/><rect x="740" y="470" width="60" height="160" rx="24" fill="#2d5a43"/>
9 <rect x="860" y="560" width="200" height="30" fill="#6b5440"/><rect x="880" y="590" width="14" height="70" fill="#6b5440"/><rect x="1026" y="590" width="14" height="70" fill="#6b5440"/>
10 <ellipse cx="560" cy="700" rx="260" ry="40" fill="#c9775b"/>
11 </svg>
static/sample-house-front.svg added +15
@@ -0,0 +1,15 @@
1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800" role="img" aria-label="Illustration: a two-storey house on a tree-lined street">
2 <defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#a9c9e6"/><stop offset="1" stop-color="#eef0e6"/></linearGradient></defs>
3 <rect width="1200" height="800" fill="url(#sky)"/>
4 <rect y="580" width="1200" height="220" fill="#7f9a63"/>
5 <path d="M0 760h1200v40H0z" fill="#5d7a47"/>
6 <rect x="260" y="300" width="680" height="290" fill="#d7c7aa"/>
7 <path d="M230 300 L600 130 L970 300Z" fill="#6b5440"/>
8 <rect x="760" y="360" width="180" height="230" fill="#c9775b"/>
9 <path d="M740 360 L850 290 L960 360Z" fill="#6b5440"/>
10 <g fill="#f6d58e" stroke="#ffffff" stroke-width="8"><rect x="310" y="340" width="110" height="90"/><rect x="470" y="340" width="110" height="90"/><rect x="310" y="470" width="110" height="90"/><rect x="800" y="420" width="100" height="90"/></g>
11 <rect x="500" y="460" width="90" height="130" fill="#2d5a43"/>
12 <path d="M480 590h130l50 170H430z" fill="#cdbb9b"/>
13 <rect x="90" y="330" width="22" height="260" fill="#6b5440"/><ellipse cx="101" cy="320" rx="95" ry="110" fill="#6f8b55"/>
14 <rect x="1080" y="360" width="20" height="230" fill="#6b5440"/><ellipse cx="1090" cy="350" rx="85" ry="100" fill="#4e6b3c"/>
15 </svg>
static/sample-house-yard.svg added +15
@@ -0,0 +1,15 @@
1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800" width="1200" height="800" role="img" aria-label="Illustration: a fenced back yard with a swing set and a patio">
2 <defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3c99a"/><stop offset="1" stop-color="#f7e3c6"/></linearGradient></defs>
3 <rect width="1200" height="800" fill="url(#sky)"/>
4 <rect y="470" width="1200" height="330" fill="#7f9a63"/>
5 <g fill="#e9dcc4"><rect x="0" y="380" width="1200" height="12"/><rect x="0" y="440" width="1200" height="12"/></g>
6 <g fill="#e9dcc4"><rect x="40" y="350" width="16" height="130"/><rect x="200" y="350" width="16" height="130"/><rect x="360" y="350" width="16" height="130"/><rect x="520" y="350" width="16" height="130"/><rect x="680" y="350" width="16" height="130"/><rect x="840" y="350" width="16" height="130"/><rect x="1000" y="350" width="16" height="130"/><rect x="1150" y="350" width="16" height="130"/></g>
7 <path d="M120 700 L220 400 H260 L360 700" fill="none" stroke="#6b5440" stroke-width="14"/>
8 <rect x="200" y="395" width="180" height="14" fill="#6b5440"/>
9 <path d="M250 410v190M330 410v190" stroke="#3e4a52" stroke-width="5"/>
10 <rect x="232" y="600" width="36" height="12" fill="#c9775b"/><rect x="312" y="600" width="36" height="12" fill="#c9775b"/>
11 <rect x="640" y="560" width="440" height="200" fill="#cdbb9b"/>
12 <rect x="700" y="500" width="320" height="16" fill="#3e4a52"/><path d="M700 516L660 560H1060L1020 516Z" fill="#6b5440"/>
13 <circle cx="880" cy="640" r="46" fill="#2d5a43"/>
14 <ellipse cx="560" cy="300" rx="120" ry="100" fill="#6f8b55"/><rect x="548" y="300" width="22" height="130" fill="#6b5440"/>
15 </svg>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs