DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Five campaigns want the same 75 emails a day, and null is how one of them says "whatever is left"

A Nakodo plan allows a number of new conversations a weekday: 25 on Pro, 75 on Business, and the pricing table lists both. A Business account can have 25 campaigns running at once.

So: 75 emails, 25 campaigns, and the obvious failure mode is that the campaign whose pipeline happens to be fullest at 09:00 takes all 75 and the other 24 get nothing all day. Nobody would describe that as broken, exactly. It just means the feature you added three campaigns ago silently stopped working.

The fix is a share per campaign. The interesting part is that the whole thing is four pure functions and a nullable integer column, and that the nullable part is what makes it bearable.

null is a share that re-computes itself

export type ShareInput = { id: string; dailyNewEmails: number | null };

export function splitDay(limit: number, campaigns: ShareInput[]): Map<string, number> {
  const set = campaigns.reduce((n, c) => n + (c.dailyNewEmails ?? 0), 0);
  const unset = campaigns.filter((c) => c.dailyNewEmails === null).map((c) => c.id);
  const rest = Math.max(0, limit - set);
  const each = unset.length > 0 ? Math.floor(rest / unset.length) : 0;
  const odd = rest - each * unset.length;
  return new Map(
    campaigns.map((c) => [c.id, c.dailyNewEmails ?? each + (unset.indexOf(c.id) < odd ? 1 : 0)]),
  );
}
Enter fullscreen mode Exit fullscreen mode

A campaign with a number keeps it. Campaigns without one divide whatever is left, and the remainder goes to the oldest ones, one each, because the campaign list is ordered by creation date and an arbitrary but stable rule beats a fair looking random one.

The alternative, which I wrote first, is to give every campaign an explicit share when it is created. It works until the second day of real use. Create a fourth campaign and you have to rewrite three rows. Delete one and you have to rewrite the rest, or quietly lose a quarter of the plan's day. Upgrade and all of them are wrong. Every one of those is a write that can half succeed.

With null, "I have not thought about this" is a state the system can represent, and adding a campaign re-splits the day by itself with no writes at all. The cost is that a share is a function of the row and its siblings rather than just the row, which is why it is a function and not a column read.

The tests are the specification:

assert.deepEqual([...splitDay(75, [c("a", null), c("b", 30), c("c", null), c("d", null)])],
  [["a", 15], ["b", 30], ["c", 15], ["d", 15]]);

assert.deepEqual([...splitDay(25, [c("a", null), c("b", null)])], [["a", 13], ["b", 12]]);

assert.deepEqual([...splitDay(25, [c("a", 40), c("b", null)])], [["a", 40], ["b", 0]]);
Enter fullscreen mode Exit fullscreen mode

That last one is the case worth staring at. A campaign with a share of 40 on a plan whose day is 25 is not a bug, it is a downgrade, and Math.max(0, limit - set) is the line that keeps the result sane instead of negative.

The clamp is asymmetric on purpose

The editor is a row of sliders. Moving one may not push the total over the plan's day, so every change goes through:

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 last line is the whole design decision. Lowering is always allowed. Raising is allowed only into room that is free.

Why that asymmetry matters: after a downgrade from Business to Pro, five campaigns can be holding shares that add up to 150 on a plan whose day is 75. If the clamp were symmetric, every slider on that screen would be stuck, including the ones you are trying to use to fix the problem. The Math.max(current, limit - others) term says: you can never go above where you already are when there is no free room, but you can always come down.

test("over the plan's day, a share can still be lowered but not raised", () => {
  const shares = { a: 40, b: 40 };
  assert.equal(clampShare(25, shares, "a", 39), 39);
  assert.equal(clampShare(25, shares, "a", 45), 40);
  assert.equal(clampShare(25, shares, "a", 0), 0);
});
Enter fullscreen mode Exit fullscreen mode

Saving is a separate check, and it is allowed to be strict where the slider is forgiving:

if (total > limit) {
  return `These add up to ${total} new emails a day, but your plan sends ${limit}. Lower some to save.`;
}
Enter fullscreen mode Exit fullscreen mode

Note what is not an error: shares that add up to less than the plan's day. Leaving ten of your 75 unassigned is a legitimate thing to want, and a validator that demands the total equal the limit turns a settings screen into a puzzle.

An upgrade has to move numbers that nobody typed

Upgrading from Pro to Business triples the day. A campaign explicitly set to 25, which was the whole of Pro's day, should not stay at 25 while 50 go unused.

export function widenShare(share: number, fromDay: number, toDay: number): number {
  return toDay > fromDay && fromDay > 0 ? Math.floor((share * toDay) / fromDay) : share;
}
Enter fullscreen mode Exit fullscreen mode

Each share keeps its proportion of the day, rounded down so the total cannot overshoot:

assert.deepEqual([7, 9, 9].map((n) => widenShare(n, 25, 70)), [19, 25, 25]);
Enter fullscreen mode Exit fullscreen mode

19 + 25 + 25 is 69 on a day of 70, so the rounding leaves one unassigned rather than putting the account one over. And a downgrade deliberately does nothing:

test("a smaller day leaves shares alone", () => {
  assert.equal(widenShare(60, 75, 25), 60);
});
Enter fullscreen mode Exit fullscreen mode

Shrinking everyone's number automatically means a customer who downgrades for a month and comes back has lost settings they typed. Leaving them too big is harmless, because splitDay and the send path both treat the plan's day as the real ceiling. The stale 60 is a preference, not a permission.

Where the purity stops

Four pure functions, one database read, and one write path. The write is the only place that needs care:

const next = rows.map((r) => ({ id: r.id, stored: r.dailyNewEmails, value: values[r.id] ?? shares.get(r.id)! }));
Enter fullscreen mode Exit fullscreen mode

When you save a change to one campaign, every other campaign that still had null gets written the number it was being shown. If it did not, changing campaign A from 15 to 20 would re-split the remainder and silently move B and C as well, and the screen you just looked at would be a lie five seconds after you left it.

So null means "whatever is left" right up until the first time anybody touches this screen, and then it stops meaning that for the campaigns that were on it. That is a small amount of extra state, and the alternative is a settings page whose numbers change in response to edits you did not make.

The whole save runs inside the per-user lock that the rest of the metering uses, for the ordinary reason: two tabs, two saves, and a total that passes the plan's day between the read and the write.

One more special case, in the reader:

const split = plan.limits.campaigns > 1;
Enter fullscreen mode Exit fullscreen mode

The Free plan allows one campaign, so there is nothing to split and the editor does not appear at all. A Free account's campaign gets the whole day whatever share it was holding from a bigger plan, which is the same "a stale number is a preference" rule as the downgrade.

The part I would tell my past self

I spent longer than I would like deciding whether dailyNewEmails should be nullable, because a nullable integer is a smell in most schemas. In this one it is carrying real information that an integer cannot: the difference between a number somebody chose and a number the system is maintaining. Collapsing those two into one column means you can never again tell whether 15 was a decision or an accident of arithmetic.

If you want to see the behaviour from the outside, the pricing table lists what each plan sends a weekday and how many campaigns it allows, and the section of how Nakodo works on emailing for you explains what one of those new conversations actually is.

Top comments (0)