Skip to content
Shipcraft

Work — case study

503 or 1 414?

Parcela — a land-analysis tool for the Polish property market.

A client came to us with a problem, not a spec: tell me whether this plot is a good buy. Six weeks later they had a working product. Everything below actually happened, including the parts where we were wrong.

15things shipped
48 hourslongest gap between them
6 weeksidea to working product

What shipped, and when

Fifteen deliveries in six weeks

Never more than forty-eight hours between them, and one task in progress at a time.

  1. Day 2: Every signal in the brief traced to a real Polish register
  2. Day 4: All four assumed sources actually called
  3. Day 6: Zoning cut; the product stops claiming what it cannot know
  4. Day 8: Every distance re-checked against a routed distance
  5. Day 10: Four properties analysed in depth
  6. Day 12: All fifteen, one record shape, no exceptions
  7. Day 14: The filter chain — 94% of raw records discarded
  8. Day 16: Findings become coded flags, and the report learns to say "I don't know"
  9. Day 18: Three design directions, each built against the same real listing
  10. Day 20: Pricing deleted; reports become free
  11. Day 22: Accounts deleted; paste a URL, get a report
  12. Day 24: The pipeline ported to Next.js 15 and TypeScript
  13. Day 26: Fidelity test — fifteen listings replayed and diffed
  14. Day 28: Cache budget measured, rate limits split in two
  15. Day 30: CI green: lint, typecheck, test, build

The listing that started it

There's a plot for sale in Zakopane. 1 970 m². The asking price works out to 503 zł per square metre, which for that part of the country is a number that makes you sit up.

About 700 m² of it can actually be built on.

On the basis that matters — the buildable part — you're paying 1 414 zł/m². Two and a half times the advertised figure. Same plot, same listing, same afternoon. One number says "call the agent today." The other says "keep scrolling."

Nothing in the listing was a lie. The seller published an area and a price, and dividing one by the other is arithmetic. It's just arithmetic that answers a question nobody was asking.

That gap is the whole product.

1 414 zł/m²503 zł/m²
Same plot, two tags. 503 zł/m² on the total area, 1 414 zł/m² on the part you can build on.

What the client brought us

The brief, in their words, was roughly: a tool that tells me whether this plot is a good buy, compared to the market around it.

That's a problem, not a spec — which is how we prefer to receive them. A spec would have said "integrate the zoning API." A problem let us find out, in the first week rather than the fifth, that the zoning API answers one question in fifteen.

So we drew nothing. Not a wireframe, not a panel, not a chart. A mock-up is a promise, and the fastest way to mislead the person paying you is to show them a screen full of numbers before you know whether anyone publishes those numbers. First we went to find out what the country actually gives you.

Day 2 — What Poland actually publishes

This is the step that usually gets skipped, and it's the one that decides whether a product exists.

We took every signal the brief implied and went looking for a real, free, queryable Polish source for each. Transaction prices: RCN, the national register, published per-powiat. Parcel boundaries: ULDK and GUGiK. Addresses to coordinates: the GUGiK geocoder. Building permits: GUNB's RWDZ. Amenities and travel times: OpenStreetMap and OSRM.

Each one came back with an answer, a shape, and a coverage level — which is what you need before you can honestly promise a client anything at all.

Day 4 — "You should actually test all of these assumed things"

That was the client, in the first week, and it was the best sentence of the build.

Four of our sources had been asserted — read the documentation, believed the documentation, written it into the plan. Never actually called. So we called them. Three checked out. One of our claims was wrong.

And the testing surfaced something worse, in the first analysis we had run by hand: a false positive. We'd flagged one listing's stated location as inconsistent with its coordinates. It wasn't. We had compared a road distance against a straight-line distance and treated the difference as a lie.

We'd have shown a buyer a warning about a seller who had done nothing wrong.

That single error set the architecture. Every distance claim in the product is now checked against a routed distance, never a straight line. One bug, caught by testing an assumption we'd have happily shipped, rewrote a rule that now applies everywhere.

The difference between these two lines is not a lie. We had been treating it as one.

Day 6 — Three rounds of cutting

Knowing what exists is not the same as knowing what survives. The cutting started, in three rounds.

Round one — the unobtainable. Municipal investment plans, environmental risk, utility connection and build-out costs. Every one of these is genuinely useful and none of them exists as data you can query. Cut.

Round two — the market that isn't there. Short-term rental yield was going to be a headline number. We tested it three ways — OSM, Booking, GUS statistics — and found roughly six hundred times less rental supply in the commuter belt than a functioning short-term-rental market would have. That isn't a data problem you engineer around. There is simply no market there to compute a yield from. Parked, honestly labelled, revisit if reality changes.

Round three — the one that hurt. Zoning plans, the miejscowy plan zagospodarowania przestrzennego. This is arguably the single most decision-relevant document for a plot of land, and the client wanted it badly. Across fifteen test properties it resolved for one. We kept the endpoint contract written down in the docs for the day coverage improves, and removed the feature from the product.

A feature that works one time in fifteen isn't a feature. It's a coin flip wearing a suit.

Every one of those conversations happened in week one, on a call, with the evidence on the table — not in week five with a signed-off design that had a zoning panel on it.

Three rounds of cutting. What survives is what Poland actually publishes.

Day 10 — Fifteen listings, run for real

Four properties in depth, then all fifteen. Three findings came out of it, and between them they justify the entire product:

The basis matters more than the price. Zakopane, above. Advertised on total area, sold on buildable area, 2.8× apart. Opposite verdicts from the same listing depending on which number you divide by.

The filter chain is the product. Raw transaction data is free and public — anyone can download it. Making it comparable is the work: filtering by parcel type, use, date window, geography, plausibility. That chain discards 94% of raw records. Skip it and you get a median that's wrong by a factor of three, delivered with total confidence. Access to the data is not a moat. Knowing which 94% to throw away is.

Coverage collapses where the registers do. Coordinates resolve every single time. Transaction data yields a usable price position for six of fifteen. That's not a bug to fix, it's a fact about Poland.

Day 12 — Making it a machine

The first pass was generating 579 lines of hand-written Polish prose per property. Beautiful, unmaintainable, and unable to survive property number sixteen.

We replaced it with a pipeline emitting one record per property: identical key set every time, no exceptions. Absence became a status — an explicit value — never a missing key. One code path now renders all fifteen properties, and would render fifteen thousand.

Then we audited that pipeline and found five real defects, including pagination silently truncating four of the fifteen samples, a distance regex that could never match anything, and a build command that overwrote the entire data store instead of merging into it.

Every one of those was invisible from the outside. The output looked fine.

Day 16 — Teaching the report to say "I don't know"

With the machine running, the question stopped being what can we compute and became what are we willing to claim.

Findings became coded flags with a separate dictionary of display labels, so what the pipeline knows and what the page says are two different things that can be changed independently. And because coverage collapses for nine listings in fifteen, the product had to be able to say, out loud, on the page: there aren't enough transactions here to tell you anything.

Most tools in this space would rather show a confident number than an honest gap. That's the difference between a report and a horoscope.

This is the point where the shape of the product was settled — and only now was there anything safe to draw.

Day 18 — Three directions, one decision

We put three design directions on the table, all built against the same real Polish listing so they could be compared honestly rather than admired separately:

  • Dossier — an editorial paper report. Serif type, single column, evidence stacked like an argument.
  • Workspace — a bento grid of analytics panels with tabs.
  • Canvas — dark, map-first, layer toggles and a comparables drawer.

Every panel on all three was something we had already proved we could fill. Nothing on those boards was a hope, and nothing had to be walked back a fortnight later.

The client picked Workspace. The other two were deleted the same day. Options exist to make a decision easy, not to be kept around as a museum.

Three directions, one decision, two deletions — on the same day.

Days 20 and 22 — Two pivots that deleted finished work

Partway through, the business model changed twice.

First: paid became free. Pricing tiers, credit counters, the whole three-tier landing page — deleted. Reports free, funded by ads on the page and never inside the report, plus a newsletter.

Second: accounts became nothing at all. No sign-up, no saved reports, no comparison view. Paste a URL, give an email, see the report once, receive it by mail. This killed the saved-properties list and the side-by-side comparison screen — two features we had designed and built, and were proud of.

Then we audited the homepage claim by claim and rewrote every promise down to something the pipeline can actually deliver, including the unglamorous one about running out of transactions.

The saved list and the comparison view. Both finished, both gone.

Day 24 — The port to Next.js and TypeScript

Everything so far ran offline, against fifteen precomputed properties. Making it a product meant moving the whole pipeline into the application: Next.js 15 and TypeScript, running per request against a live URL.

Porting an analysis pipeline is not a translation job. Python and JavaScript disagree about things you never think about until they cost you an afternoon, and a quiet disagreement in a number is worse than a crash — a crash you notice. So the port was pinned by a fidelity test: all fifteen original listings replayed through the new TypeScript pipeline from captured upstream responses, and the output diffed field by field against the original Python.

That gate earned its keep on the first run. It caught a regex that could never match anything, and a rounding disagreement between Python's banker's rounding and JavaScript's. Neither would have been found by eye. Both would have shipped as numbers that looked perfectly reasonable and were wrong.

Fifteen listings, identical output, before a single new feature went near the new codebase.

Days 28–30 — The boring part that keeps it alive

Cache budget set from measured compression in the database — 6.4× — rather than a number someone felt good about.

Rate limits split in two, because they defend different things: an hourly limit guards a free public register from roughly 0.9 GB an hour of our traffic, and a daily limit guards a hard third-party ceiling of 100 emails a day. One limit cannot express both.

CI running lint, typecheck, test and build on every push. Standing it up surfaced two gaps worth closing: there was no linter installed yet, and the build expected a production database secret to exist. The linter went in, and the database client now connects lazily — so a build needs no secrets at all.

Nobody asks to see this room. It's the reason the ship arrives.

What the six weeks bought

Nobody hires us to write regexes. Here's what the client got:

  • Fifteen deliveries, never more than forty-eight hours apart, one task in progress at a time.
  • Not one mock-up promised a number nobody publishes. Sources were proved before anything was drawn, so no screen ever had to be walked back.
  • A feature that resolved 1 time in 15 was removed in week one, not discovered in week five with a design already signed off.
  • A revenue model changed twice and finished work was deleted both times without argument.
  • Four assumed data sources were actually called, and the one that was wrong was found before a user met it.
  • A warning that would have accused an honest seller was caught and killed by a test.
  • Every promise on the homepage was audited against what the code can do.

The deliverable is a working product. The value is everything we refused to ship on the way there.

Bring us a problem, not a spec.

See pricingBook a fit call