Case study · Internal tool

BW Client Tracker

A staffing allocation tool for an engineering design firm. Project managers own clients, each client gets a pool of hours per month, and the owner assigns those hours to named people — so the screen says at a glance what's unassigned, who's overloaded, and who's past their monthly cap.

  • Built at the request of a team at Barry-Wehmiller Design Group
  • Not an official BW product
  • Frontend and data layer complete · backend planned

The problem

An engineering design firm sells hours. A client contracts for some number of hours in a month, and the project manager who owns that client has to spread those hours across engineers. Three questions come up constantly: how many hours does each client still have unassigned this month, how loaded is each person, and who is over their monthly cap and needs to be pulled off something.

In a spreadsheet all three of those require manual math that goes stale the moment somebody edits a cell. The tool exists so those three numbers are always correct and always visible without anybody computing anything.

This one didn't start as a portfolio idea. Someone at Barry-Wehmiller Design Group — a firm doing packaging line and industrial systems work — described a problem they actually have at work and asked whether I could build it, so I did. Roughly 20 people at the firm are the intended daily users.

I built this independently, at their request. It is not an official Barry-Wehmiller product, and I'm not affiliated with or employed by the company.

The approach

Scale is small on purpose: roughly 10 clients and 10 to 20 people. That constraint drove real decisions. The entire month fits on one screen, so there's no pagination, no virtualization, and nothing hidden behind tabs — the extra room goes to whitespace and bigger numbers instead. It's desktop only, because these are people on large monitors doing planning work, not phones.

Architecture

BW Client Tracker data architecture Workspace UI components · providers permissions.ts pure functions lib/data/index.ts the seam · every read and write schemas.ts Zod validation fixtures · store in-memory · today route handlers Supabase · RLS · planned same call sites either way

lib/data/index.ts exports async functions returning promises — getClients(month), getAssignments(month), updateAssignment(id, hours), and about thirty more. Right now they resolve against in-memory fixtures. When the API lands they become fetch('/api/…') calls and not a single call site changes. Four things make that swap actually cheap rather than theoretically cheap:

There's a deliberate 120 ms delay in the fixture layer so loading states stay visible during development. Seven entities are stored — Person, ClientType, Client, ClientMonth, Assignment, TransferRequest, Notification — and nothing derived is among them.

Four month states

The visible window is the current month plus five ahead, with a scrubber pinned to the bottom. A month is past (read-only, and hidden from the scrubber until you scrub backward), current (editable), future with a pool record (editable), or future without one — locked, with an empty state prompting hour entry.

That last state is the one that matters. Client hours change month to month, so a future month can't accept assignments until somebody has actually entered that month's pool. The existence of a ClientMonth record is what makes a month "set," so the lock falls out of the data model instead of being a UI flag — it's enforced in the data layer, not just by hiding buttons, and there's a test proving it. Past months hide themselves until you scroll backward, borrowed from the way iOS hides a search bar until you pull down.

The filter algebra

There are three filter groups — clients, people, and type letters. Defaults are OR inside a group and AND across groups, which is what people expect. But every single joint is flippable: between two chips in a group there's a small AND/OR button.

That's worth building because ANDing two people asks whether they're on the same client, which is a completely different question from whether either of them is on it — "these two work together" versus "these two are busy." At both levels AND binds tighter than OR, so a mixed run evaluates as an OR of ANDs in a single pass, the same as it would anywhere else. Connectors are stored per group rather than per position, so emptying a group out doesn't shift the operators on the groups around it.

A describeFilters function renders the whole expression back in plain English, and the export panel shows it under a "Reads as" line, so nobody exports a file whose filter logic they misread. Selected chips animate to the top of their group with a real FLIP position animation.

The totals rail carries a related subtlety. Pool and Open always reflect the full pools of the visible clients regardless of any person filter, because a client's open hours don't change just because you filtered by a person. When a person filter is on, a separate "Selected people" line appears instead of quietly corrupting the main totals.

Ownership and permissions

Any signed-in user can create clients, and they own what they create. A client has a name, a single-letter type, and a pool of hours for a given month. createdById is immutable and never changes; ownership is the only thing that moves, and it moves only when the owner initiates a transfer and the recipient accepts it — both parties see the pending state in the meantime, and accepting moves edit rights without touching assignments.

Every person has a monthly hour cap, defaulting to 160; going over it turns that person's bar red. Type letters come before labels, because in practice the letters get used long before the firm agrees what they stand for — clicking a letter opens a field to name it, naming is firm-wide so every client on that letter picks it up at once, and an unnamed letter takes whatever name arrives with it while a letter that already has one keeps it. Anyone can name a type, because an admin-only gate would leave most of the firm staring at letters nobody was allowed to explain.

Undo that can't rot

Cmd+Z and Cmd+Shift+Z (Ctrl on Windows), plus the top two items in the menu, which name what they'll reverse — "Undo change Maya Tran's hours," not a bare "Undo."

The stack holds snapshots rather than hand-written inverse operations. That was a deliberate call: inverses are where undo systems rot, because every new write needs a matching un-write, and the day somebody forgets one the stack quietly corrupts the data it's supposed to protect. A snapshot can't drift from the operation it undoes because it is the state from before it. Checkpoints get taken inside each write after validation and the permission check but before the first mutation, so a refused write never lands on the stack, and the stack gets dropped when the acting person changes, so undo can never reverse a write the permission model wouldn't have allowed.

Export that can't disagree with the screen

One row per assignment, respecting the active filters exactly as the screen shows them — the rows come from the same view object the workspace renders, so the file can't disagree with what was on screen. CSV and Excel both, with columns for month, client, type letter and label, owner, creator, client pool hours, person, person hours, monthly cap, and whether they're over it.

The panel opens with a generated filename in an editable field and the extension pinned beside it, so renaming can't produce something Excel refuses to open. Whatever you type gets sanitized before it reaches the download: path separators become hyphens, Windows reserved and control characters get dropped, leading dots go, and length is capped. Clearing the field falls back to the generated name instead of saving a file called .csv.

A footnote I'd rather be honest about than hide: the npm build of SheetJS is 0.18.5 and carries advisories about parsing untrusted spreadsheets. This app only ever writes spreadsheets and never reads them, so those don't apply here. SheetJS's own CDN build is the upgrade path if that ever changes.

Design

The visual reference was Robinhood, but for structure rather than palette: mostly monochrome with one accent doing nearly all the semantic work, numbers treated as the design — large tabular figures, tightly tracked, right-aligned in columns, with weight signaling importance before you read the value — and whitespace and hairline dividers instead of boxing everything in cards.

Testing and accessibility

Where it's going

As of now the full frontend and data layer are built and working against in-memory fixture data. There's no auth, no database, and no email yet, and it isn't deployed. The architecture exists specifically to make adding those cheap, and the plan is to have it actually used by the firm rather than to stop at a screenshot.

Stack

← Back to all projects