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;
// ...
}
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}` : ''}`,
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 } };
}
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++;
}
}
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 {
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;
}
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());
};
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.');
}
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';
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)