DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A plan's day is one bar, and a slider can only grow into room that is free

Nakodo emails creators and local businesses on a brand's behalf, and a plan buys a number of new conversations a day rather than a number of emails. Our pricing page publishes the numbers: Pro runs 5 campaigns at a time and contacts 25 creators or businesses each weekday, Business runs 25 campaigns and contacts 75.

Those two numbers together are the whole problem. Five campaigns and 25 a day means a busy campaign can eat the day by lunchtime and the other four get nothing, so each campaign has its own share, and the shares have to be editable in a way that cannot produce an invalid total. The screen that does it is 264 lines, and most of the thinking is in two of them.

One bar, not five numbers

The panel shows the plan's day as a single bar, with each campaign's share as a segment and what is left over as empty track.

<div className="flex h-2.5 gap-px overflow-hidden rounded-sm bg-paper-150" role="presentation">
  {parts.filter((p) => p.n > 0).map((p) => (
    <div
      key={p.id}
      className={cn("h-full transition-[width] duration-200 motion-reduce:transition-none", p.id === highlight ? "bg-cyano-700" : "bg-ink-400")}
      style={{ width: `${(p.n / Math.max(limit, total)) * 100}%` }}
    />
  ))}
</div>
Enter fullscreen mode Exit fullscreen mode

The denominator is Math.max(limit, total), not limit. A total can exceed the plan's day, and it does so in one real situation: an account on Business with 25 campaigns downgrades to Pro, and the stored shares now add up to more than the smaller day. With limit as the denominator the segments would overflow their container and the bar would lie about its own scale. With max(limit, total) the bar stays full width and the sentence under it carries the problem:

{over > 0
  ? `${n(total)} set aside, ${n(over)} more than your plan's ${n(limit)} a day. Lower some to save.`
  : `${n(total)} of ${n(limit)} new emails a day set aside${total < limit ? `, ${n(limit - total)} free` : ""}`}
Enter fullscreen mode Exit fullscreen mode

That paragraph is the accessible version of the bar, so the bar itself is role="presentation" and the sentence is aria-live="polite". Dragging a slider changes one number in a sentence a screen reader reads, instead of changing the width of a div a screen reader cannot see. The free remainder is only mentioned when there is one, because "0 free" is noise.

A share can always fall and can only rise into free room

Every change goes through one pure function, which is shared with the server:

export function clampShare(limit: number, shares: Record<string, number>, id: string, wanted: number): number {
  const current = shares[id] ?? 0;
  const others = Object.entries(shares).reduce((n, [k, v]) => (k === id ? n : n + v), 0);
  const n = Math.max(0, Math.round(Number.isFinite(wanted) ? wanted : 0));
  return n <= current ? n : Math.min(n, Math.max(current, limit - others));
}
Enter fullscreen mode Exit fullscreen mode

The asymmetry is the point. Lowering is always allowed, raising is capped at what the others leave free. The naive version, Math.min(wanted, limit - others), is wrong in exactly the downgrade case: when the others already use more than the whole day, limit - others is negative, and a user trying to lower a share from 25 to 10 would be clamped to 0. Hence the Math.max(current, ...): the ceiling is never lower than where you already are.

The hook around it is six lines, and the clamp is inside the setter, so no component can set an invalid value:

export function useShares(limit: number, initial: Record<string, number>) {
  const [values, setValues] = useState(initial);
  const total = Object.values(values).reduce((a, b) => a + b, 0);
  return { values, total, free: limit - total, set: (id, n) => setValues((v) => ({ ...v, [id]: clampShare(limit, v, id, n) })) };
}
Enter fullscreen mode Exit fullscreen mode

Two screens use that hook: this panel, and the "Daily emails" step of the campaign setup flow, where one of the rows is the campaign being created.

The number box that shows what you typed

Sliders are fine for a rough split and useless for "make this one exactly 12". So each row has a slider and a number input, both driving the same clamp. A controlled number input over a clamping model has a known annoyance: type 30 when only 18 are free, and the field snaps to 18 under your fingers, usually mid-keystroke.

const [typed, setTyped] = useState<string | null>(null);
<Input
  type="number"
  value={typed ?? String(value)}
  onChange={(e) => {
    setTyped(e.target.value);
    const v = Number.parseInt(e.target.value, 10);
    if (!Number.isNaN(v)) onChange(v);
  }}
  onBlur={() => setTyped(null)}
/>
Enter fullscreen mode Exit fullscreen mode

While the box has focus it shows the characters you typed; the model still clamps, so the bar and the sentence show the real value immediately. On blur the local string is dropped and the box falls back to the clamped number. You see your own typing, and you also see that the system disagreed, in the two places that matter.

The slider is the shadcn primitive with one addition, a thumbLabel prop, because a drag handle with no accessible name is a drag handle nobody can use:

<Slider value={[value]} min={0} max={limit} step={1} onValueChange={([v]) => onChange(v)} thumbLabel={`New emails a day for ${c.name}`} />
Enter fullscreen mode Exit fullscreen mode

Saving the whole allocation as one field

The allocation is saved together or not at all, so the form posts a single JSON field through a Server Action:

<input type="hidden" name="shares" value={JSON.stringify(shares.values)} />
Enter fullscreen mode Exit fullscreen mode

The Save button is disabled={shares.total > limit}, which is a convenience and not a guarantee, so the server runs the same rules again:

const parsed = shareMap.safeParse(parseJson(form.get("shares")));
if (!parsed.success) return sharesLost();
const { changed } = await withUserLock(user.id, (tx) => setShares(tx, user.id, values));
Enter fullscreen mode Exit fullscreen mode

shareMap is z.record(z.uuid(), z.number()) with a 200-key limit. Inside the lock, setShares re-reads the plan and the campaigns, refuses ids that are not the user's, and runs the shared sharesProblem check that produces the same sentence the client shows. It also only writes rows whose value actually changed, and only writes an audit row if at least one did, so opening the panel and pressing Save changes nothing and records nothing.

One more rule lives on the read side. A campaign created before shares existed has null, which means "whatever the others leave":

const rest = Math.max(0, limit - set);
const each = unset.length > 0 ? Math.floor(rest / unset.length) : 0;
const odd = rest - each * unset.length;
Enter fullscreen mode Exit fullscreen mode

The remainder goes to the oldest campaigns first, one each. And on an upgrade, stored shares grow in proportion rather than staying put, so 25 of Pro's 25 becomes 75 of Business's 75; a downgrade leaves them alone, because the clamp above can lower them whenever the user next opens the panel.

Details that only exist because of how the panel is opened

The panel is reachable from a link elsewhere in the app with ?manage on the URL, which means the component can be opened by navigation as well as by its own button. Closing it then has to clean up after the link:

const change = (next: boolean) => {
  setOpen(next);
  // Opened from a link (?manage): closing it drops that, so a reload doesn't reopen it.
  if (!next && new URL(window.location.href).searchParams.has("manage")) {
    window.history.replaceState(null, "", window.location.pathname);
  }
};
Enter fullscreen mode Exit fullscreen mode

replaceState rather than a router push, because this is not a navigation anyone should be able to go back to. And the form inside is mounted only while the panel is open, so every time it opens it starts from the saved shares rather than from the numbers you abandoned last time.

The screen itself is behind sign-in, so the only way to see it is to have an account. What is public is the arithmetic it has to respect: the per-plan daily numbers and the campaign counts on pricing, including the line that says extra campaigns are paused rather than deleted if a plan gets smaller, which is the same downgrade case the clamp is written for. How it works covers where those daily conversations come from.

Top comments (0)