DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Seventeen words for eight database values, and a dot that only pulses when work is actually running

Nakodo does its work while nobody is watching: it reads a brand's website, finds creators and businesses who fit, writes the first email and handles the replies. The how it works page is the public version of that. The private version is a screen that has to answer, in about four words per row, what is happening right now.

That answer lives in three small files totalling 106 lines. None of them touches the database, and none of the words in them is stored anywhere.

Eight values, seventeen words

The conversation table has a status column and a closed_reason column. Both are plain text with a TypeScript enum over them, because this app has no pgEnum anywhere. The words a human reads are a pair of records:

export const STATUS_LABELS: Record<OutreachStatus, string> = {
  drafting: "Writing",
  review: "To approve",
  queued: "Scheduled",
  contacted: "Contacted",
  talking: "Talking",
  needs_you: "Needs you",
  handed_off: "Introduced",
  closed: "Closed",
};

export const CLOSED_LABELS: Record<OutreachClosedReason, string> = {
  no_reply: "No reply",
  declined: "Declined",
  opted_out: "Opted out",
  bounced: "Address bounced",
  complained: "Marked as spam",
  cancelled: "Stopped",
  suppressed: "Can't be contacted",
  no_email: "No email",
  failed: "Failed",
};

export function threadLabel(t: { status: OutreachStatus; closedReason: OutreachClosedReason | null }): string {
  return t.status === "closed" && t.closedReason ? CLOSED_LABELS[t.closedReason] : STATUS_LABELS[t.status];
}
Enter fullscreen mode Exit fullscreen mode

Seventeen words for what the machine thinks of as eight states, and the reason is the three-line function at the bottom. A closed conversation that knows why it closed never shows the word "Closed", because "Closed" is a true statement that answers nothing. "No reply" and "Marked as spam" and "Opted out" are the same state to a scheduler and three completely different things to the person whose brand sent the email: one is normal, one is a deliverability problem, and one is a person telling you to stop.

Typing both as Record<Union, string> rather than Record<string, string> is the whole safety net. Add a ninth closed reason to the schema and the build fails in this file until it has a word. That is the cheapest possible test for "no state reaches the UI without a human writing a sentence for it".

Five views over eight statuses

The same file groups them, in display order, with satisfies doing the checking:

export const THREAD_VIEWS = [
  { key: "attention", label: "Needs you", statuses: ["needs_you", "review"] },
  { key: "active", label: "In progress", statuses: ["drafting", "queued", "contacted", "talking"] },
  { key: "introduced", label: "Introduced", statuses: ["handed_off"] },
  { key: "closed", label: "Closed", statuses: ["closed"] },
  { key: "all", label: "All", statuses: null },
] as const satisfies readonly { key: string; label: string; statuses: readonly OutreachStatus[] | null }[];
export type ThreadView = (typeof THREAD_VIEWS)[number]["key"];
Enter fullscreen mode Exit fullscreen mode

as const satisfies is the pairing I reach for most often in this codebase: as const keeps the literal types, so ThreadView is "attention" | "active" | ... derived from the data, and satisfies checks every status string against the schema union without widening anything. A plain type annotation would have given me string for the keys and no union to derive.

Two things in that array are decisions rather than data. "Needs you" collapses two statuses, because a first email waiting for approval and a reply the model would not answer are both "a human has to look", and a product that makes you learn the difference before you can act has failed at the one job. And statuses: null for "All" means absent rather than a list of everything, so adding a status never silently drops it out of the one view that promises completeness.

The badge is not a traffic light

// A conversation's state. Introduced is the one stamp; anything waiting on
// the brand is ochre-outlined; work in flight is plain ink; closed stays
// quiet. No traffic-light colours.
const TONE: Record<OutreachStatus, string> = {
  drafting: "border-dashed border-ink-400 text-muted-foreground",
  review: "border-mark-outline text-foreground",
  queued: "border-dashed border-foreground/50 text-foreground",
  contacted: "border-foreground/60 text-foreground",
  talking: "border-cyano-700 text-cyano-800 dark:border-cyano-300 dark:text-cyano-200",
  needs_you: "border-mark-outline bg-mark/30 text-foreground",
  handed_off: "border-cyano-700 bg-cyano-50 text-cyano-800 dark:border-cyano-300 dark:bg-cyano-950 dark:text-cyano-200",
  closed: "border-ink-400/60 text-muted-foreground",
};
Enter fullscreen mode Exit fullscreen mode

Eight tones, two hues, and no red or green anywhere. The brand document is blunt about why: ochre is "used only for attention marks", so the moment a colour means "danger" or "success" it stops meaning "look here". A closed conversation is not a failure, a reply is not an alarm, and three quarters of the states are neither good nor bad, so the axis the colours actually encode is "does this need you", not "is this going well".

The signals are also doubled on purpose. Only two states get a fill, filled is "waiting on you" and "introduced", and the attention dot used elsewhere carries its own text:

export function AttentionMark({ label, className }: { label: string; className?: string }) {
  return (
    <span className={cn("inline-flex", className)}>
      <span className="inline-block size-[7px] rounded-full border border-mark-outline bg-mark" aria-hidden="true" />
      <span className="sr-only">{label}</span>
    </span>
  );
}
Enter fullscreen mode Exit fullscreen mode

Seven pixels of ochre and a screen-reader sentence. The dot is decoration, the sr-only text is the information, which is the only arrangement that survives WCAG 1.4.1 and a colourblind reader.

The dot that tells you whether anything is happening

A campaign gets a different treatment: one word and a dot, where the dot answers a question the word cannot.

export function LiveDot({ live, label, className }: { live: boolean; label: string; className?: string }) {
  return (
    <span className={cn("inline-flex items-center gap-1.5 text-[13px] text-muted-foreground", className)}>
      <span className="relative inline-flex size-1.5">
        {live && (
          <span className="absolute inset-0 rounded-full bg-cyano-600 motion-safe:animate-[live-pulse_2.4s_ease-out_infinite]" />
        )}
        <span className={cn("relative inline-flex size-1.5 rounded-full", live ? "bg-cyano-600" : "bg-ink-400")} />
      </span>
      <span className={cn(live && "text-foreground")}>{label}</span>
    </span>
  );
}
Enter fullscreen mode Exit fullscreen mode

A solid dot with a pulsing copy behind it, motion-safe so it is a static dot for anybody who has asked for less motion, and the label's own colour as the second channel so the state is legible without the animation.

And then the function that decides live, which is 32 lines and the densest product thinking in the app:

// Campaign status as a live dot and a word. Only running work pulses: a
// campaign with only its emails paused is still searching, and one with
// enough good leads waiting may still be checking what it found.
export function campaignStatus(
  status: string,
  working: boolean,
  pauseMode: PauseMode | null = null,
  enoughLeads = false,
): { live: boolean; label: string } {
  switch (status) {
    case "researching":
      return { live: true, label: "Reading website" };
    case "draft":
      return { live: false, label: "Brief to review" };
    case "active":
      if (enoughLeads) return { live: working, label: "Enough leads" };
      return working ? { live: true, label: "Surveying" } : { live: false, label: "Up to date" };
    case "paused":
      return pauseMode === "emails"
        ? { live: working, label: "Emails paused" }
        : pauseMode === "finding"
          ? { live: false, label: "Finding paused" }
          : { live: false, label: "Paused" };
    case "failed":
      return { live: false, label: "Research failed" };
    default:
      return { live: false, label: status };
  }
}
Enter fullscreen mode Exit fullscreen mode

Four inputs, nine possible labels, six database statuses. The two branches worth staring at are the ones that return live: working rather than true or false.

"Emails paused" with a pulsing dot looks wrong until you remember that pause here has a reason column and three answers: a campaign can be paused for sending while it carries on searching. The word tells you what is stopped, the dot tells you that something is still moving. If live were hardcoded false for every paused campaign, the screen would claim nothing is happening while jobs run, and the next thing that appears would be unexplained.

"Up to date" versus "Surveying" is the same trick with the opposite emphasis: same status, same plan, and the only difference is whether any job is open right now. "Idle" would have been a worse word, because nothing is wrong, and "Active" would have been a worse word, because nothing is happening.

The branch that should not exist

default:
  return { live: false, label: status };
Enter fullscreen mode Exit fullscreen mode

That line prints a raw database value into the interface. active instead of "Surveying", in lower case, in a dot label. It exists because the first parameter is typed status: string and not the schema's union, so TypeScript cannot see that the switch is exhaustive and insists on a return.

Printing the raw value is the right behaviour for a branch that should be unreachable: it does not crash, it does not say "Unknown", and anybody who sees active on a card knows immediately that a status was added and this file was not told. But the honest fix is one word in the signature. Narrow status to the union, the switch becomes exhaustive, the default goes away, and the next person to add a status gets a compile error in the same place the conversation labels already give them one.

Three files, 106 lines, 26 words, and not one of them in the database. The nearest public equivalent is the glossary, where the same discipline is applied to the words customers meet before they sign up: one definition each, written once, and never two names for one thing.

Top comments (0)