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.
-
Everything derived is derived, never stored. Client
assigned hours, client open hours, person load, and the over-cap
flag are computed from the assignments every time they're needed, by
pure functions in
lib/derive.ts. Storing any of them would invite them drifting out of sync with the records they come from — which is exactly the spreadsheet failure mode the tool is replacing. -
One seam for all data access. Every read and write
goes through
lib/data/index.ts. No component imports fixtures directly, ever. -
Permissions as pure shared functions. Five
functions in
lib/permissions.ts, no React and no component imports, so the API routes can later import the exact same functions. - Snapshot undo instead of inverse operations. The stack holds state from before each write rather than hand-written un-writes.
- One meaning for red. Red appears for exactly one condition across the entire app — a person over their monthly cap — so a red thing on screen always means the same thing.
Architecture
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:
- They're already async even though fixtures are synchronous, which forced the loading and error states to get built in this pass rather than retrofitted after the API exists. That retrofit is where this kind of project usually rots.
-
They already validate through shared Zod schemas in
lib/schemas.ts, and the route handlers will import those same schemas for request bodies. -
They already enforce permissions through
lib/permissions.ts. One source of truth means the server can't accept a request the UI hid the button for. -
The acting person is set once via
setActingPersonId— the way a session cookie will identify the caller later — rather than being threaded through every write signature.
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.
- Admins and the client's owner can edit the client's name, type, and pool, edit any assignment on it, and transfer ownership.
- Everyone else can edit none of that — including their own hours on that client. That row is deliberate: the firm wanted allocation to be a single-owner decision. A read-only row still renders fully legible; the only thing that changes is that the owner's name gains weight.
-
Only admins can edit a monthly cap or grant admin. Two seeded admins
live in an
ADMIN_EMAILSconstant, compared lowercased, because the real addresses are written with capitals and nobody types them that way.
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.
-
Tokens, not hardcoded values. Every color, radius,
spacing step, type size, and motion timing is a CSS custom property
in
globals.css, mapped into Tailwind through@theme inline. Light theme by default, dark as a toggle, both designed rather than one being an inversion of the other. - Color discipline. Bars are graphite by default. Green appears only when a bar lands exactly on capacity and red only past it, so a screen full of bars still reads at a glance instead of turning into one solid block of color.
-
Motion with two registers. 170–240 ms ease-out
for anything that simply changes state; a stiff, well-damped spring
for things that fly out of a surface. Every route is wrapped in
MotionConfig reducedMotion="user", soprefers-reduced-motionturns the filter reflow into an instant reorder, drops the past-month slide, and stills the springs. - The navigation is a diamond. No wordmark, no row of header icons — a diamond in the top left rotates on hover and drops undo, redo, export, appearance, and whichever page you aren't looking at. It's a real button underneath, so it opens on focus, toggles on Enter or Space, walks its items with arrow keys, and closes on Escape. A transparent bridge spans the gap between the mark and the panel so crossing it doesn't count as leaving, and a click on the mark opens rather than toggles, because a toggle would close the menu the hover just opened.
Testing and accessibility
-
Vitest unit tests cover the derivations, permissions, month logic,
filters, export, the data layer, and the history stack.
npm run test,npm run typecheck, andnpm run buildall run clean. -
TypeScript strict with
noUncheckedIndexedAccess, and noanyanywhere. - Visible focus everywhere; arrow keys move within a filter group, step the month scrubber, walk the diamond menu, and increment hour inputs. Hour inputs reject negatives and non-numbers at the input layer, so bad values never reach state.
- Cmd+Z and Cmd+Shift+Z are ignored while the caret is in a text field, where they belong to the browser's own text undo.
-
A dev-only role switcher, gated behind
NODE_ENV === 'development', moves the acting user between an admin, a client owner, and someone assigned to clients who owns none. Without auth, that's the only way to actually exercise the permission model, and building it early saved a lot of guessing.
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.
-
lib/data/index.tsbecomesfetchcalls to Next.js route handlers.lib/permissions.tsandlib/schemas.tsget imported by those handlers unchanged, and no component moves. - Supabase (Postgres) with row-level security, so permissions are enforced at the database as well as at the route.
- Auth via email one-time code gated to the firm's domain, through a custom SMTP provider. Email verification is required so nobody can talk their way into a firm's staffing data, and everyone creates their own account — no admin-created placeholder people.
- The session-scoped undo stack gets paired with a server-side audit log, since snapshot undo is a client convenience and an audit trail is a different problem.
- Deployment on Vercel.
Stack
- Next.js (App Router)
- TypeScript (strict)
- Tailwind CSS v4
- Framer Motion
- Zod v4
- Vitest
- SheetJS
- Supabase (planned)
- Vercel (planned)