lans.cloud is ~65 free single-purpose web tools. Two of them — a family chore chart and a classroom jobs chart — do the same core thing: they take a list of people and a list of tasks and generate a fair week-by-week rotation. Everyone does every job exactly once per cycle, no arguments, no draws.
Both also let you override the machine. The rotation is a starting point; if you want Emma on dishes this week even though the algorithm picked her brother, you tap the cell and change it. The tool remembers your edit, marks it with a little "manually changed" badge so you can see what you touched, and lets you reset it later.
That override layer had a bug that had been shipping for weeks. It was the quiet kind — no crash, no error, no red in any test. It just occasionally told a confident lie about who was doing the dishes.
The shape of the lie
Here's how an override was stored. One map, keyed by week, then by task:
// week index (as string) → task index (string) → person
export type OverrideMap = Record<string, Record<string, string>>;
And here's how it was applied when rendering a week — walk the computed assignments, and if there's an override at that position, use it:
const assignments = week.assignments.map((computed, taskIndex) => {
const manual = row[String(taskIndex)];
return manual && people.includes(manual) ? manual : computed;
});
Read that key again: row[String(taskIndex)]. The override is bound to the task's position in the array, not to the task itself.
You can already see the trap. Say your chores are:
0: dishes
1: pets
2: vacuum
You override task 0 (dishes) → Emma. Stored as { "0": "Emma" }. Fine.
Now you delete "dishes" from the list, because this week nobody's on dishes. The array shifts up:
0: pets
1: vacuum
The override map still says { "0": "Emma" }. Position 0 is now pets. So the chart cheerfully renders "Emma — pets," stamps it with the manually changed badge, and there is nothing anywhere to tell the parent that this isn't what they typed. Emma covers the pets now. The tool is certain you asked for it.
Delete a row and every override below it slides one task up the list, each one landing on a different chore and each one still wearing the badge that says "a human chose this on purpose." It's the worst class of bug: silent, plausible, and dressed up as intent.
Why it survived so long
Because at rest, everything is internally consistent. The map is valid. The keys are valid. Every unit test that built an override and read it back passed, because none of them deleted a task between the write and the read. The bug only exists in the gap between two user actions across two sessions — exactly the gap that lives in localStorage and never shows up in a test that sets up and tears down in one function.
The root cause is a category error I think is worth naming, because it's everywhere once you look: we keyed persisted state by position when we meant to key it by identity. An array index is a fine handle within a single render — it's stable, it's cheap. But the moment you write it to disk and the array can change underneath it, the index stops pointing at a thing and starts pointing at a slot. Slots get reused. Identity doesn't.
The fix: carry the name
The override needs to remember which task it was for, not just where it was. So the stored value grows from a bare string into a small object:
type OverrideEntry = { person: string; task: string }; // task = the expected NAME
export type OverrideMap = Record<string, Record<string, OverrideEntry>>;
And applying an override now checks that the task sitting at that index is still the one the override was made for:
function entryMatchesTask(
entry: OverrideEntry,
taskIndex: number,
tasks?: string[]
): boolean {
if (!entry.task || !tasks) return true; // legacy / caller opted out → by index
return tasks[taskIndex] === entry.task;
}
If the name matches, the override applies. If the task at that index is now something else, the override is simply dropped — the cell falls back to the computed rotation, and crucially, the "manually changed" badge doesn't render either, because there's no longer a manual change there. Emma goes back to whatever the fair algorithm picked for pets, and the chart stops pretending otherwise.
The write path fills in the name it's pinning to, so going forward every override knows its own task:
next[week][String(task)] = { person: name, task: tasks[task] ?? '' };
The part I'm actually pleased with: it heals itself
Here's the constraint that makes this kind of change interesting rather than trivial: people already have data. There are chore charts sitting in browsers right now with overrides stored in the old bare-string shape. A migration that throws those away, or worse crashes on them, is a broken promise — these tools keep everything in localStorage precisely so nothing is ever lost.
So the sanitizer that runs every time the data is read from storage accepts both shapes:
let person = '';
let taskName = '';
if (typeof value === 'string') {
person = value.slice(0, 60); // legacy bare string
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
const v = value as Record<string, unknown>;
if (typeof v.person === 'string') person = v.person.slice(0, 60);
if (typeof v.task === 'string') taskName = v.task.slice(0, 60);
}
if (person) row[task] = { person, task: taskName };
A legacy string loads as { person, task: '' }. That empty task is the "I don't know which task this was for" signal — and look back at entryMatchesTask: an empty name means it falls back to the old index-based behavior. So old data keeps working exactly as it did before (bug and all, for that one un-verifiable entry) — until the user next edits that cell, at which point the write path stamps the real task name onto it and the entry is healed forever.
No migration script, no version flag, no big-bang rewrite of everyone's stored blob. The data upgrades itself, one edit at a time, and the storage keys never change. That's the pattern I reach for whenever a persisted shape has to evolve: widen the reader to accept the past, and let the writer pull data into the present as people touch it.
An honest detour: the test that lowered coverage
One thing worth admitting, because it surprised me. The bug is in a component's display binding, so I wrote a component test that renders the real UI and drives the actual input — the only way to genuinely lock the behavior. Then the pre-push hook failed on the coverage gate.
Adding a test dropped the number. The project only measures files that tests import, and this 449-line component had no test before, so it wasn't in the denominator at all. My test pulled the whole thing in at 59% covered and dragged the global average under its floor. The metric had been high partly because a big untested component was invisible to it.
The fix was to write a thorough test rather than a token one — drive the presets, the goalkeeper selector, the save/load flow — which took that component from 0 to 93% and pushed the global back over the line. A good reminder that coverage-of-tested-files rewards you for never testing your scariest code, and that "the number went down when I added a test" can mean the number was lying.
The lesson
Persisted state is a contract with your past self, and array indices make a terrible signature on that contract. If a stored reference needs to survive the collection it points into being reordered, deleted from, or grown — and persisted references almost always do — store something about the thing, not where the thing sat. A name, an id, a slug. Then teach your reader to notice when the world has moved and the reference no longer matches, and to step aside quietly when it doesn't.
Emma is back on dishes. And when she isn't, at least the chart is honest about it.