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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/5-three-sample-listings-for-the-meeting-on → main 1 commit fork
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 | ||
| 35 | 35 | Print it, or save it as a PDF. "Rewrite all" writes every asset again in |
| 36 | 36 | another tone (your edits are replaced, so it asks with a tooltip first). |
| 37 | 37 | |
| 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 | |
| 39 | 39 | never empty; "Start a new one from this" copies any listing into the form. |
| 40 | 40 | |
| 41 | 41 | **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 () => { | ||
| 61 | 61 | const page = await (await fetch(base + "/")).text(); |
| 62 | 62 | expect(page).toContain('name="address"'); |
| 63 | 63 | expect(page).toContain("412 Alder Lane"); |
| 64 | expect(page).toContain("88 Meridian Street"); | |
| 65 | expect(page).toContain("27 Birchwood Court"); | |
| 64 | 66 | expect(page).toContain("Fair Housing: clear"); |
| 65 | 67 | expect(page).toContain("/static/sample-front.svg?v="); |
| 66 | 68 | // Planted once: a second boot on the same data plants nothing. |
| 67 | 69 | const again = openDb(dir); |
| 68 | 70 | 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 }); | |
| 70 | 72 | }); |
| 71 | 73 | |
| 72 | 74 | 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| @@ -38,19 +38,94 @@ export const SAMPLE: ListingInput = { | ||
| 38 | 38 | export const SAMPLE_PHOTOS = ["sample-front.svg", "sample-garden.svg"]; |
| 39 | 39 | export const SAMPLE_HEADSHOT = "sample-agent.svg"; |
| 40 | 40 | |
| 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. */ | |
| 42 | 123 | export function seed(db: Database): void { |
| 43 | 124 | 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 | } | |
| 55 | 130 | })(); |
| 56 | 131 | } |
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> |
new above
offline