Nakodo has one screen behind the login that a new account lands on, and at the bottom of it, until it is no longer true, there is a checklist: start a campaign, review and launch, wait while Nakodo finds creators, approve the first emails. A progress bar, a tick, one hint under whichever step is next.

It is 118 lines in src/app/(app)/campaigns/checklist.tsx, it is used in exactly one place, and there is no table behind it. No onboarding_steps rows, no completed_at columns, no dismiss button, nothing to migrate when a step changes. It is derived from the campaign rows the page has already loaded for other reasons.

This post is that design, what it costs, and the bug writing it up found.

Progress as a projection

The component takes a list of campaign summaries. Each one carries what the list page already selects anyway:

type CampaignSummary = { id: string; kind: CampaignKind; status: string; launchedAt: Date | null; hasOffer: boolean; autoSend: boolean; conversations: number; contacted: number; }; 

Every step's done is an expression over that:

const launched = campaigns.some((c) => c.launchedAt); const steps: Step[] = [ { label: "Start a campaign", done: true, ... }, { label: "Review and launch", done: launched, ... }, ..., { label: `Wait while Nakodo finds ${many}`, done: campaigns.some((c) => c.conversations > 0), ... }, ]; 

"Start a campaign" is done: true as a literal, which looks like a joke and is not: the component only renders when there is at least one campaign, so by the time anybody sees the list, that step is finished. It is on the list because a checklist whose first item is already ticked is a checklist you believe. Starting at zero of four makes a reader feel behind before they have read it.

The two conditional steps are the ones that make this worth writing about.

Save your proposal only exists for a campaign that was launched without one:

...(launched && !campaigns.some((c) => c.hasOffer) ? [{ label: "Save your proposal", done: false, href: `${base}/outreach/proposal`, hint: "..." }] : []), 

Normally the proposal is part of the review page you see before launching, so the step would be a lie: it was done as part of step two. It appears only for the people for whom it is a real outstanding task.

Approve the first emails only exists if the account has not turned on automatic sending:

if (!latest.autoSend) { steps.push({ label: "Approve the first emails", done: false, href: `${base}/outreach?view=attention`, hint: "..." }); } 

So the list is four steps, or five, and {done} of {steps.length} done counts whichever list you got. A denominator that moves is usually a sign of a bug. Here it is the point: the checklist is a description of your remaining work, not a fixed curriculum, and an account that will never see an approval queue should not be told it has a step it cannot do.

One hint, on the step that is next

const next = steps.findIndex((s) => !s.done); if (next < 0) return null; 

next is the first unfinished step. It gets a ring instead of a dashed circle, a highlighted row, and it is the only step whose hint is rendered. Finished steps get a tick and line-through. Steps after it are dashed circles with nothing under them.

Four hints exist in the file and at most one is on screen. That was a deliberate trade: the first version showed every hint, and a five item list with a sentence under each item is not a checklist, it is a page of documentation with ticks on it. The reader has exactly one next action, so exactly one sentence is relevant.

The state is also announced, because a tick and a ring are nothing to a screen reader:

<span className="sr-only">{s.done ? " (done)" : current ? " (next)" : ""}</span> 

The whole row is a Link, so the target is the full width of the card rather than the label, and the circle with the tick in it is aria-hidden because the text next to it now says the same thing.

Two ways to disappear

const latest = campaigns[0]; if (!latest || campaigns.some((c) => c.contacted > 0)) return null; 

and

if (next < 0) return null; 

The first is the interesting one. Once any campaign has contacted anybody, the checklist is gone, whether or not its steps are finished. Somebody whose campaign is already emailing creators does not need to be told to approve the first emails: the product is working, and a getting-started panel on a working account is noise with a progress bar on it.

That is also what makes the no-storage design safe. contacted only ever goes up, so the checklist cannot come back from the dead and ask you to start again. There is no dismiss button because there is nothing to dismiss: the condition that shows it stops being true by itself.

The cost is real, though, and it is worth naming: because nothing is stored, a step cannot be skipped. If you deliberately never want a proposal, there is no "not for me". Your only way out of that step is doing it, or contacting somebody, which removes the whole panel. For a four item list shown to brand new accounts I will take that trade. For a longer one I would not.

The bug this post found

Look again at the subjects of those expressions.

const latest = campaigns[0]; // the newest campaign const launched = campaigns.some((c) => c.launchedAt); // any campaign ...!campaigns.some((c) => c.hasOffer) // any campaign done: campaigns.some((c) => c.conversations > 0) // any campaign if (!latest.autoSend) // the newest campaign const base = `/campaigns/${latest.id}`; // every link 

Three of the five steps ask "do any of your campaigns have this", two ask about the newest one, and every link in the list points at the newest one.

For one campaign, which is the whole audience this panel was written for, those are the same question. For two they are not. Start a second campaign while the first one is running and the checklist says "Review and launch" is done, because the first one was launched, with a link to the new one's brief page. The proposal step vanishes because the old campaign has a proposal. The waiting step is already ticked because the old campaign has conversations.

It is not a crash and it is not visible to most accounts, because the panel disappears as soon as the first campaign contacts anybody, and a first campaign usually gets there before a second one is created. That is exactly the kind of bug that lives for months.

The fix is to pick one subject and say so in the type. Either the checklist is about one campaign, in which case it takes a campaign and not a list, or it is about the account, in which case latest.autoSend and the hrefs need to be account-level questions too. I think it is the first: this panel is "get your first campaign going", so it should take latest and nothing else, and the three some calls become reads of one object.

One file, 118 lines, no migration either way. Nakodo describes the same journey in prose on how it works, which is the version everybody sees before they sign in, and which has the advantage of not having to know which campaign you mean.