About the project

## Inspiration

Every apartment listing in New York is written by someone who wants you to sign it. The public record isn't — and it's all there. HPD logs every violation. The city records every housing court case, every eviction, every bedbug filing. The problem is that it's scattered across a dozen datasets keyed by tax lot numbers no tenant has ever heard of.

We wanted the fifteen minutes before signing a lease to be informed by the same data a housing lawyer would pull.

The number we built the whole product around is deliberately simple:

$$\text{open violations per apartment} = \frac{\text{open HPD violations}}{\text{residential units}}$$

Raw violation counts punish big buildings. A 200-unit tower with 40 open violations is in far better shape than a 20-unit walk-up with 40. Normalizing per apartment is what makes two buildings comparable — and it's the difference between a number that looks alarming and a number that means something.

## What it does

Type a NYC address. The city flies to your building, lights it up in 3D, and hands you its record:

- **Open HPD violations per apartment**, broken out by class — A non-hazardous, B hazardous, C immediately hazardous
- **A letter grade** with every threshold, input and weight printed on screen next to it, each linking to its dataset
- **Enforcement history** — housing court cases, executed evictions, bedbug filings, Buildings Department violations
- **The landlord's other buildings**, joined on the managing agent, with combined open violations
- **Area rent** from the Zillow Observed Rent Index, with a five-year trend and a straight-line projection whose arithmetic is printed underneath
- **Rent stabilization status**, and the Rent Guidelines Board cap if it applies
- **Average apartment size and rent per square foot**, so a neighbourhood rent index becomes comparable with an actual listing
- **Aerial photographs** with the building's footprint outlined
- **A plain-English summary** written by Gemini from the figures on the page and nothing else, narrated by ElevenLabs
- **A tenant rights page** transcribed from HPD's own guidance

Three real buildings we test against:

| | 609 W 180 St | 500 W 175 St | 500 W 18 St |
|---|---|---|---|
| Open violations | 430 | 99 | 0 |
| Apartments | 20 | 58 | 235 |
| **Per apartment** | **21.50** | **1.71** | **0.00** |
| Grade | F | — | B |

## How we built it

**Next.js 16 (App Router), React 19, TypeScript, Tailwind, Vercel.** No auth, and no database on the critical path — Socrata is queried live through server route handlers so the app token never reaches the browser.

The entry point is NYC Planning Labs' geosearch, which turns an address into a BBL and BIN. Everything else keys off those. Each data source is one module in `lib/nyc/`, and every fetcher returns the same shape:

```typescript
type Result<T> = { ok: true; data: T } | { ok: false; reason: string }

That signature is the single most important design decision in the codebase, and the reason is in the next section.

The map is one Mapbox GL instance mounted in the root layout, never unmounted, driven imperatively through a tiny pub/sub channel — so the city doesn't blink between searching and reading, and the report pages stay server components.

Camera framing is computed, not constant. Zoom comes from the building's own footprint extent and roof height:

\( \text{zoom} = \log_2\left(\frac{156543 \cdot \cos(\phi)}{\text{span} / \text{targetPixels}}\right) \)

After arrival, the surroundings are bucketed into twelve compass sectors, each scored by \( h/d \) — height over distance, so near-and-low obstructs as much as far-and-tall — and the camera swings to the clearest side, taking the smallest turn that gets there.

Gemini writes the summary from the page's own figures. ElevenLabs narrates it. Tiger Data (TimescaleDB) holds the rent index as a hypertable and the stabilized-building list as a table — 32ms versus 1251ms against the raw CSV, a 39× read — but nothing depends on it: both fall back to the CSV, verified by pointing DATABASE_URL at a dead host and watching the pages render identically.

186 offline unit tests, a separate live smoke suite, and Playwright for visual verification.

Challenges we ran into

A filter that failed silently and would have shipped a lie. The HPD violations dataset has no bbl column. Filter on it anyway and Socrata doesn't error — it returns count: 0. Our first working build reported every building in New York as spotless, confidently. The fix is filtering on boroid, block and lot as unpadded strings. A test now asserts the query never contains bbl again.

That one bug set the architecture. If a data source can fail by returning a plausible zero, then a failed source must never render as zero — which is why every fetcher returns Result<T> and the UI says "not enough data" rather than "0".

More of the same class:

  • PLUTO returns BBL as a float string, "1021310044.00000000"
  • The rent index CSV has a quoted column containing a comma ("New York-Newark-Jersey City, NY-NJ-PA"), so a naive split misaligns every rent column after it and reads the wrong month
  • The rent-stabilized dataset is Git LFS — raw.githubusercontent.com serves a pointer file, not data

Believing our own memory about APIs. We wrote the Gemini integration from recall and got every single detail wrong: the package name, the endpoint, the response shape. Over REST there is no output_text — the answer lives in steps[] entries of type model_output. Thinking tokens count against max_output_tokens, so our 320-token cap was eaten by ~570 tokens of reasoning and returned a sentence truncated at 35 characters. And the model we'd picked is capped at 20 requests per day on the free tier — a handful of demo clicks. Verifying against the live API instead of recalling it became a rule.

export const revalidate does not cache route handlers. Every play of the narration regenerated the audio — six seconds, different bytes, real credits. Replaced with an explicit module-level cache and an in-flight guard: 10.8s cold, ~5ms after, and a concurrent burst collapses to one generation.

The map was broken for hours, and our checks kept saying it worked. Three bugs stacked: the Vercel environment variable wasn't prefixed NEXT_PUBLIC_ so it was never inlined; a negative z-index painted the canvas behind an opaque body background; and absolute inset-0 was overridden by mapbox-gl.css setting position: relative, collapsing the container to zero height. Every assertion we wrote reported "map container: rendered" — because it was. Asserting on DOM markup is not verification of something visual. We installed Playwright and it found the third bug in one screenshot.

A grade that ranked backwards. A building at 1.71 violations per apartment scored worse than one at 21.50, because both saturated the top severity band and the cleaner building happened to have bedbug filings. We added extreme tiers and made the bands mutually exclusive. A regression test pins the ordering.

Nearly inventing housing law. We were about to state that Class C violations must be corrected in 24 hours. HPD sets correction deadlines per violation, printed on the Notice of Violation — there is no deadline per class. A tenant could have acted on that. There is now a test that fails if any time period appears next to correction language anywhere in the rights content.

Landlords hide behind LLCs by design. Joining on PLUTO's owner name finds exactly one building, every time — because NYC landlords register one company per building. Joining on the HPD-registered managing agent found thirteen.

Accomplishments that we're proud of

Nothing on the page is a black box. No composite 0–100 score, no model, no learned weights. The grade is rule-based, and its inputs, thresholds and weights are printed on screen beside it, each linking to the dataset it came from. A judge can audit it in ten seconds — that was the bar, and anything that couldn't clear it didn't ship.

The tenant rights content is transcribed, not generated. Not one word of it came from a language model. Tests enforce that every citation resolves to a nyc.gov, ny.gov or nycourts.gov host. Wrong housing law is worse than no housing law.

Failures are visible. 186 tests, and the ones we're proudest of are the negative ones — the test that fails if the violations query regains a bbl filter, the test that fails if an invented correction deadline appears.

Every number links to its source. All of it is public data. We just made it readable before you sign.

What we learned

Civic data doesn't fail loudly. It returns 0, or a pointer file, or a column shifted by one — and every one of those renders as a confident, wrong answer. In this domain, designing for silent failure isn't defensive engineering, it's the whole job.

We also learned that being fast and being sure are different activities, and that we're worse at telling them apart than we thought. Three separate times we "verified" something by checking the thing adjacent to it — markup instead of pixels, a test fixture instead of the live API, a rebuild instead of the server actually serving it. Every one cost more time than checking properly would have.

What's next for knowyourbuilding

  • Where this ZIP sits against the rest of the city — "cheaper than 78% of NYC ZIP codes" turns an abstract $3,204 into a decision
  • Rent burden — area rent against median household income from the Census ACS
  • Registered rent-stabilized unit counts per year from DOF tax filings, which is far richer than the presence/absence signal we have now
  • Street-level photographs alongside the aerial views
  • Complaint-to-violation lag — how long this landlord actually takes, computed from the record rather than asserted ```

Built With

Share this project:

Updates

Submission history