DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Three schools called Trinity, 947 rows with no ids, and why clearing a row writes a record instead of deleting one

CogniPrep sells practice for psychometric tests and assessment centres, and some of the people who most need to know it exists are sixth form and university careers staff. So there is an internal admin screen with 947 rows on it to phone: 885 sixth forms and 62 university careers services, researched once and committed as JSON.

There is no database table behind it. The rows are in git, the call outcomes are in localStorage, and the interesting problems all come from that second half.

The rows have no ids, because nobody issued any

A call log needs a key per row. Rows that came out of a database arrive with one. Rows that came out of research do not, so the key has to be built from the fields, and the obvious candidate does not work:

export interface CallListEntry {
  /**
   * Key into the call log. The name alone is not unique: seven sixth form names
   * appear twice (three Trinity Schools), so a school's postcode is part of it.
   */
  id: string;
  name: string;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Seven duplicate names out of 885 is not an edge case you can wave away, because the failure mode is that logging a call to one Trinity School marks all three as called. The key is built on the way in:

id: `${kind}:${row.n}${row.c ? `|${row.c}` : ''}`,
Enter fullscreen mode Exit fullscreen mode

kind is in there as well, so the schools list and the universities list cannot collide, and so one key tells you which list it belongs to without a lookup. And it is conditional on the postcode, because a handful of rows do not have one. Those degrade to name only, which is the best available answer rather than a silent collision.

A cleared row is not an absent row

The log only holds rows something has happened to. Clearing a row back to "not called" looks like it should delete the key. It does not:

/**
 * Sets a school's outcome and note.
 *
 * A row cleared back to "not called" keeps a blank record rather than being
 * deleted. Its timestamp is what stops an older backup, imported later, from
 * bringing the cleared outcome back.
 */
export function updateCallLog(log, id, change, now = Date.now()): CallLog {
  const note = change.note.trim();
  const current = log[id];
  const unchanged = current
    ? current.status === change.status && current.note === note
    : change.status === null && note === '';
  if (unchanged) return log;
  return { ...log, [id]: { status: change.status, note, updatedAt: now } };
}
Enter fullscreen mode Exit fullscreen mode

That blank record is a tombstone, and it exists because the log moves between devices as a JSON file. The operator calls from a phone, logs from a laptop, and reconciles by exporting a backup from one and importing it into the other. The merge is last write wins:

for (const [id, record] of Object.entries(incoming)) {
  const mine = log[id];
  if (!mine || record.updatedAt > mine.updatedAt) {
    log[id] = record;
    updated++;
  }
}
Enter fullscreen mode Exit fullscreen mode

With a tombstone, "I marked this not interested by mistake and cleared it" is a record with a newer timestamp, and importing yesterday's backup leaves it cleared. Without one, the absence of a key is indistinguishable from never having touched the row, and the stale "not interested" comes straight back.

The unchanged check at the top is the other half of the same idea. Reopening a note dialog and closing it without typing must not write a new timestamp, because a timestamp is what wins a merge. Returning the same object means no write, no new timestamp, and no re-render.

Parsing is also where the wrong file gets caught

parseCallLog takes unknown and checks a format version, then every field of every record, dropping records it does not recognise. The return type matters more than the validation:

export function parseCallLog(value: unknown): CallLog | null {
Enter fullscreen mode Exit fullscreen mode

null means "this is not a call list backup at all", which is a different thing from "this is a backup with nothing in it". The import handler uses the distinction:

if (!incoming) {
  notifyError('That file is not a call list backup.');
  return;
}
Enter fullscreen mode Exit fullscreen mode

Picking the wrong file out of a downloads folder is a thing that happens hourly. An importer that returns an empty object for a holiday photo would report "0 schools updated" and look like it worked.

Two tabs, and a browser that refuses to save

Two things about localStorage that only show up in use.

A second tab on the same screen writes the same key, so this one has to stop trusting its own copy:

const onStorage = (e: StorageEvent) => {
  if (e.key === CALL_LOG_STORAGE_KEY || e.key === null) setLog(readCallLog());
};
Enter fullscreen mode Exit fullscreen mode

e.key === null is the case the docs mention and nobody handles: that is storage being cleared wholesale, not one key changing.

And writing can simply fail, in private windows and when a quota is full. writeCallLog returns a boolean rather than throwing, and the screen tells the operator once:

if (writeCallLog(log)) {
  saveWarned.current = false;
} else if (!saveWarned.current) {
  saveWarned.current = true;
  notifyError('This browser is not saving the call list. Export a backup before you close it.');
}
Enter fullscreen mode Exit fullscreen mode

A ref rather than state, because this must not re-render, and it resets on the next successful write so a transient failure does not silence the warning forever.

The 947 rows never reach the bundle

One structural rule holds the whole thing together. The shared module, the one the client component imports for types and helpers, does not contain the data:

import 'server-only';
import raw from '@/lib/admin/call-list.json';
Enter fullscreen mode Exit fullscreen mode

The rows carry names, direct lines and staff email addresses. A client component importing them would put all of that into a static JavaScript chunk, which anyone can fetch whether or not they can get past the admin gate. So the data loads in a server-only module and is handed down as props, and the contract file stays free of it on purpose.

The useful generalisation: localStorage is a perfectly good store when the data is one person's working notes, but you still have to answer the two questions a database would have answered for you. What is a row's identity, and what does deleting one mean. Get the second one wrong and your merge quietly undoes work.

If you want to see what the calls are about, it is practice by test format, assessment centre exercises and the provider specific practice games.

Top comments (0)