Northstar
A single workspace for running a career transition: plan the day, ship the project, track the search, and see whether any of it is working. Nine sections, one domain model, and a full stack behind it: React on the front, serverless functions, Prisma and PostgreSQL on the back.
The problem
Changing careers is a project with no manager. There is a curriculum to get through, a portfolio to build, companies to research, people to contact and applications to chase. Each of those lives in a different tool that knows nothing about the others. The spreadsheet doesn't know you skipped Tuesday. The calendar doesn't know which skill that session was for. Nothing can tell you whether the whole thing is on track, because nothing sees all of it.
Northstar is the answer to a narrow question: what would it take to see the entire effort on one screen, and have it stay honest when the plan slips?
What it does
Nine sections, each one a view onto the same data rather than a separate silo.
How it's built
The architecture is one rule applied consistently: each layer only knows about the one below it.
types/ defines the domain and nothing imports upward from it.
store/ holds a copy of the account so the interface stays
instant. lib/ is pure logic: plain functions, no React, no
store access. Pages read from the store, call into lib/ for
anything computed, and render components. Nothing in
components/ reaches into the store; data arrives as props.
Behind that sit five serverless functions. One reads the whole account in a single request, one handles registration and sessions, one writes rows in any collection, one covers settings and reviews, and one prunes abandoned guest accounts. Prisma owns the schema, and the migrations are versioned so the database rebuilds from scratch on any machine.
That last constraint on components is the one that pays off. Because no
component can fetch its own state, every component is trivially reusable and
the Components page can render the whole system in isolation. And because
scheduling and funnel maths live in lib/, they can be tested
without mounting a single React tree. There are 110 unit
tests over exactly that layer.
Three decisions worth explaining
The naive version of a study planner marks a missed session as failed and
moves on. That is exactly the behavior that makes people abandon the
tool in week three. In Northstar, skipping a session pushes its content to
that area's next slot: the plan stretches instead of shedding
work. The trade-off is that "what is scheduled today" becomes a computed
value rather than a stored one, which is why it lives in
lib/pacing.ts as a pure function of the plan and your
history.
A planner that ships an improved roadmap has an awkward question to answer:
what happens to someone who already has data? The plan carries a
planVersion, and the plan itself, the roadmap, goals,
schedule and skill paths, is replaced when that number moves ahead.
Anything the person recorded themselves is left alone. Shipping a better
plan never costs anyone their history.
Date bugs are the quiet killers in any scheduling app: parse a date string
as UTC in one place and as local time in another, and entries start
landing on the wrong day for anyone west of Greenwich. There is exactly
one definition of today and one local-midnight parser, both in
lib/dates.ts, and nothing else is allowed to construct a
date. It is the single most tested file in the project.
Going from local to a real backend
The first version kept everything in localStorage. That was
deliberate: a backend brings accounts, sessions, a privacy story and a
hosting bill with it, and none of that would have told me whether the
product was any good. Keeping it local meant the questions that actually
decided that, the pacing, the merging, the funnel model, got the time
instead.
Once those were answered the constraint became the problem: data lived in one browser on one machine, and clearing site data erased it. So the second half of the project was replacing that with something real, without rewriting the half that already worked.
The pure-function layer is what made that possible. Because
lib/ never knew where the data came from, swapping
localStorage for an API meant adding one module,
lib/api.ts, and pointing the store at it. The scheduling logic
did not change at all.
Three more, once there was a server
Every change is applied locally first and sent afterwards, so the interface never waits on the network. The risk with that pattern is the screen showing something that was never saved. So a failed write says so and reloads from the server, rather than leaving you with a version of your data that only exists on your screen.
The session is a signed token in an HttpOnly,
Secure, SameSite=Lax cookie: no script can read
it and no other site can borrow it. Passwords are hashed with bcrypt.
Every write is scoped by the session's user as well as by row id, so a
guessed id matches nothing that is not yours.
Deleting an account deletes everything in it, and that happens because the schema cascades rather than because some function remembers to tidy up. Erasure that depends on code being called is erasure that eventually is not. The demo accounts are pruned on a schedule by the same route.
What's next
The app is finished in the sense that matters: it is deployed, it has accounts, and I use it every day to run my own search. What I would pick up next is not features.
- End-to-end tests over the flows that unit tests cannot reach, starting with registration and the guest path.
- Rate limiting on the auth routes, which is the obvious gap in a public sign-up form.
- A second pair of eyes on the schema. It has never been reviewed by anyone but me, and that is the part I would most like to be wrong about.