The first screen behind the login in Nakodo is a list of campaigns with a few panels around it: what is waiting on you, how much of your plan you have used, and a getting-started checklist. It is 255 lines of server component, and the thing I like about it is that none of those panels has any state of its own. There is no onboarding_steps table, no dismissed_checklist column, no last_seen_tips JSON. Every tick, every bar and every empty state is computed from rows that exist for another reason.
That is a decision with costs, and the costs are more interesting than the benefits.
The checklist has no table
const steps: Step[] = [
{ label: "Start a campaign", done: true, href: inSetup(latest) ? `/campaigns/new/${latest.id}` : base, hint: "" },
{
label: "Review and launch",
done: launched,
href: inSetup(latest) ? `/campaigns/new/${latest.id}` : `${base}/brief`,
hint: `Check the proposal and anything Nakodo guessed, then launch. Nakodo starts finding ${many}.`,
},
...(launched && !campaigns.some((c) => c.hasOffer)
? [{ label: "Save your proposal", done: false, href: `${base}/outreach/proposal`, hint: "..." }]
: []),
{
label: `Wait while Nakodo finds ${many}`,
done: campaigns.some((c) => c.conversations > 0),
href: base,
hint: "It writes to the best fits on its own as they turn up, a few each day.",
},
];
Four steps, sometimes five. "Start a campaign" is done: true as a literal, because the checklist only renders when at least one campaign exists, so the first step is a fact about the page rather than a question. "Review and launch" is launchedAt !== null. "Wait while Nakodo finds creators" is conversations > 0. The fifth step, approving the first emails, is pushed on only when the account has turned automatic sending off.
The third step is the one worth reading twice. Saving a proposal is normally part of the setup wizard, so for almost everybody it is not a step at all. It only appears for a campaign that was launched without one, which happens if the setup flow was interrupted. A stored checklist would have had to decide at signup whether that row exists. A derived one answers the question at render time and the answer is simply the truth.
Then the whole thing is conditional on two lines:
const next = steps.findIndex((s) => !s.done);
if (next < 0) return null;
and, at the top:
if (!latest || campaigns.some((c) => c.contacted > 0)) return null;
Nothing dismisses it. It disappears when there is nothing left in it, or as soon as anyone has actually been contacted, because at that point the account is past getting started and a tutorial panel on the main screen is noise.
next does double duty: it is the early return, and it is which row gets the ring, the background tint, the hint text and an sr-only "(next)". Only one hint is ever on screen, which is the difference between a checklist and a wall of advice.
The honest cost of deriving it
A derived checklist can come back.
Delete every campaign, start a new one, and you are a beginner again as far as this panel is concerned. A dismissed_at column would have made that impossible: once gone, gone for ever. I think the derived version is right, because the panel answers "what is the next thing to do with this account" and after deleting everything the honest answer is "launch something". But it is a real difference in behaviour that falls out of where the state lives, not out of any product decision, and it is the kind of thing that surprises you in a support thread six months later.
The progress bar has the same property. It is done / steps.length as an inline width, so adding the conditional fifth step moves the bar backwards: four of four becomes four of five when a campaign turns out to need a proposal. Percentages computed from a list whose length can change are always a little bit of a lie, and the alternative, a stored denominator, is a worse lie.
The panel that is rendered twice
The same component appears in two places in the tree:
{/* On phones, anything waiting goes above the campaigns rather than below them. */}
{list.length > 0 && waiting.length > 0 && <WaitingOnYou waiting={waiting} className="lg:hidden" />}
...
{list.length > 0 && (
<WaitingOnYou waiting={waiting} className={waiting.length > 0 ? "max-lg:hidden" : undefined} />
)}
On a wide screen, "waiting on you" belongs in the right column next to the usage meters. On a phone there are no columns, and a panel in the second column renders under a list of campaigns that can be any length, which is the wrong place for the only thing on the page that needs a human. Moving it with CSS order was not available: the two positions are in different grid children, not siblings in one flow.
So it is rendered twice with mutually exclusive visibility, which costs some duplicated HTML and gains a layout nobody has to scroll to. The display: none means only one copy is in the accessibility tree at a time, so a screen reader hears it once.
The asymmetry in the second line is the part I had to think about. When there is nothing waiting, the phone copy is not rendered at all and the desktop copy loses its max-lg:hidden, which means the empty state exists only in the sidebar. The empty state is a sentence and a drawing of our mascot asleep:
{/* Nothing to do, so Alcy naps. */}
<AlcyAsleep size="m" className="mx-auto -mb-3" />
On a phone, "nothing is waiting on you" is worth zero pixels, because the absence of the panel says it. On a desktop the sidebar would otherwise have a hole in it, and a napping bird is a nicer answer than a hole. Same component, same data, two different judgements about whether nothing is worth saying.
One link, three destinations
Every campaign card is a link, and where it goes depends on how far the campaign got:
const href = inSetup(c)
? `/campaigns/new/${c.id}`
: c.status === "draft" || c.status === "failed"
? `/campaigns/${c.id}/brief`
: `/campaigns/${c.id}`;
with
export const inSetup = (c: { status: string; launchedAt: Date | null }) =>
c.status === "draft" && c.launchedAt === null;
A campaign still in the wizard reopens the wizard where it left off. One whose research failed opens its brief, which is where the error and the retry live. Everything else opens its results. The rule is "put me back where the work is", and it is three lines of ternary rather than a status-to-route map, because there are three answers and a map of three entries reads worse than the question it answers.
Five queries and one write
The page does its reads in parallel, including the search params, which in Next 16 are a promise like any other:
const [listed, account, waiting, daily, sp] = await Promise.all([
campaignsNow(),
accountForPage(user.id),
threadsNeedingYou(user.id),
shareCampaigns(user.id),
searchParams,
]);
and then does something I am still slightly uneasy about:
// Research cut off mid-run is put right only when the list shows some, so
// the page doesn't write on every visit.
const list = listed.some(researchIsStale) && (await recoverStaleResearch(user.id)) > 0 ? await campaignsNow() : listed;
A render that repairs data. The guard is that the write only happens when the already-fetched rows prove there is something to repair, so the common path is a pure read, but a GET that can update rows is still a GET that can update rows. The alternative is a cron that sweeps for interrupted research, and the reason it is here instead is latency: the person looking at the screen is the one who cares that a campaign stopped mid-research, and they are looking at it now.
What the public page says
The how it works page explains eight things the product does. The checklist asks the customer for four, and three of those four are "look at this and press launch". That gap is the whole design brief for a screen like this: the product can be complicated, the list of what you have to do cannot be.
Top comments (0)