RWILD

Airplan

An internal project and portfolio management application for an enterprise IT organization — intake, scoring, quarterly planning, roadmapping, sprint execution and executive reporting across 31 pages — built directly on top of the database the company was already running on, so it reads and writes the records everyone else uses.

Airplan case study hero with a release timeline from v0.1.0 in May to v0.7.0 in August
31
Pages
74,486
Lines, frontend
29
Pure modules
517
Tests

The data was fine. There was no front door.

The IT organization already kept its portfolio in a relational database: projects, demand requests, tasks, sprints, milestones, teams, scoring records. Nothing was missing. But the only way to use it was to open tables.

So everybody built their own way around it. Prioritization happened in a spreadsheet that was copied out and pasted back. Requesters emailed to ask where their request had got to, because there was nowhere to look. Every quarter, the recurring run-the-business work was retyped by hand. And the three questions a portfolio exists to answer — who owns this, when does it land, what is blocking it — took four tables and a guess.

Airplan replaces the tables with a surface. Not a dashboard on top of a copy of the data: a real read-write application sitting inside the same database, so there is exactly one set of records and no sync to go stale.

A planning tool, planned

Two releases in May, then eight quiet weeks rebuilding the shell underneath — and twelve releases in the nineteen days that followed. The long bar is not a gap in the work; it is the work that made the rest of it fast.

Five problems, five decisions

  1. 01

    The grid moved while you scored it

    Prioritization re-sorted on every keystroke, so the row you were rating slid out from under the cursor. People scored in a spreadsheet instead.

    Fix:

    Ranking became explicit. Scores save instantly; order is frozen for the session. A Refresh ranking control marks itself updated when the current scores imply a different order, and you re-rank when you're ready.

  2. 02

    Requests went into a void

    A new request was a row with no review state. Nobody could tell an unread request from a triaged one, and the person who filed it had nothing to check.

    Fix:

    An intake queue and a requester's page. Incoming requests split into needs review and reviewed, stamped with who and when. A separate page shows each requester every project they asked for, with stage and age.

  3. 03

    Recurring work was retyped every quarter

    Each run-the-business stream needed a fresh project per quarter, with a dozen required fields, and every unfinished task moved across by hand.

    Fix:

    The quarter rolls itself over. Next quarter's projects are created 15 days before the current one ends, fully filled in, and every still-open task follows its stream across the boundary.

  4. 04

    "Is this milestone clear to land?"

    A milestone's dependencies were a comma-joined run of task names. It told you which tasks were linked and nothing about whether any of them were finished.

    Fix:

    Dependencies as a real table. Status, owner and due date per dependency, sortable and filterable — and any one of them opens in a side rail without leaving the milestone.

  5. 05

    Every list grew its own filter chips

    Each page added chips until the toolbar ran out of room, and no two pages filtered the same way.

    Fix:

    One Filter button, everywhere. A single button opens a panel holding every facet for that page. The toolbar keeps its room for search and actions, and the button takes the accent colour whenever anything is applied.

The decisions worth showing

Ranking is a decision, not a side effect

A row edited into failing the active filter stays put, with a note saying why, until you navigate away. A record vanishing the instant you touch it is the fastest way to make people distrust a tool.

Every facet behind one control

On all 31 pages, with is / is not on each. The button carries a count and takes the accent colour when anything is applied, so an unexpectedly short list explains itself.

One component, three milestone surfaces

Preview, editor and create share it, so they cannot drift apart. Opening a dependency slides a rail over the milestone instead of navigating away, and the rail wraps the same shared task form used everywhere else.

Every number opens the rows behind it

A KPI you cannot interrogate is a number to argue with; a KPI that opens its own record list — sortable, searchable, editable in place — ends the argument in the same click.

"Open" has exactly one definition

Everything except done and won't do — and the same function that decides what the rollover carries also produces the open counts shown on every stream row. The number on screen is a promise about what will happen, not a separate calculation that can disagree.

Usable as somebody else

A two-layer allow-list lets an authorised admin preview the entire product through another person's permissions, and a single hook supplies the effective identity to the whole tree — so access rules are testable by walking through them.

How it was built

Airplan runs as a custom interface extension: a React 19 application loaded inside the database platform, holding live table references rather than a copied dataset. There is no separate backend and no ETL. That buys real-time correctness and costs you every safety net a server would have given you — so most of the engineering went into putting those nets back.

// Pages — 31 files

  • One file per page, each subscribing only to the tables it needs
  • A wrapper → inner split: the wrapper resolves tables and owns the loading state, the inner component receives references guaranteed to be non-null
  • Tables resolve by id, not by name, so a rename in the database does not take the app down

// Shared surface — design system

  • Tokens, primitives and every cross-cutting behaviour in one place
  • Light/dark twin palettes, resizable side rails that remember each person's width
  • Tables that pan sideways instead of squeezing columns into slivers
  • Anchored popovers, a rich-text editor, a threaded notes panel, and one filter control

// Domain modules — 29 files, zero dependencies

  • All the logic worth arguing about lives in plain ES modules with no React and no platform SDK
  • Fiscal-quarter ordering, the quarterly rollover, dependency graphs and cycle detection, planning trees, template application, filter adapters, popover placement, markdown conversion
  • Data in, data out — that single boundary is what makes the whole thing testable

// Safe access — the iframe reality

  • Every field read goes through a guarded accessor, and an error boundary wraps the root
  • A missing table renders as absent rather than as zero
  • A schema that has drifted must never be able to report an empty portfolio as a healthy one

How it stays built

517 tests — pure logic, tested plainly

Because the domain modules import nothing, the suite runs on the language's own test runner with no framework, no bundler and no browser. Roughly 7,900 lines of tests over 8,900 lines of pure logic.

Contracts — tests that read the source

Interface rules that can't be unit-tested are asserted against the source text instead. One contract enumerates every anchored popover by name and asserts the count — so a new dropdown that would get clipped by a scroll container fails the build rather than shipping as "this field has no options."

Lint delta — fail on new problems only

A recorded baseline of existing findings lets the build fail on net-new lint while a 74,000-line codebase adopts strict rules incrementally. Strictness arrives without a big-bang cleanup nobody has time for.

Parse gate — syntax can't reach production

Every source file is transformed ahead of release to catch syntax errors up front. In an environment with no type checker, a parse gate is the cheapest possible floor.

Release — nothing resolves over the network

Dependencies are committed as a reviewed archive and the publish tool is pinned, so a release installs nothing it hasn't already reviewed. A manifest lists the evidence a release must carry: approval reference, source revision, notes, and a verified live version.

Docs — documentation as data

In-app help is a structured data set rendered by a small block renderer, opened from a launcher on every page. Updating it is part of shipping, not a follow-up — so the help never describes last month's product.

Three things I'd carry to the next one

  1. 01

    Put the boundary where the tests need it

    Pulling every rule into dependency-free modules was the highest-leverage decision in the project — not because it was elegant, but because it turned an untestable iframe application into 517 fast tests.

  2. 02

    An interface rule is only real if something enforces it

    The bug that made a picker look empty had already been fixed once on a different picker. It came back because the guard enumerated four components by name and a fifth was written. Contracts have to count, not list.

  3. 03

    Consistency is a feature people can feel

    One filter control, one task form, one definition of "open." Every place those were allowed to diverge, someone eventually found the seam — and stopped trusting the number on the screen.

Built with

Stack

  • React 19
  • Airtable Blocks SDK
  • Plain ES modules
  • node:test

Timeline

v0.1.0 May 2026 → v0.7.0 August 2026 · 14 releases · designed and built solo. Interface screens in the original case study are recreations drawn with invented projects and people.

Stack

React 19

Airtable Blocks SDK

Plain ES modules

node:test