Version 1 of this app was used for real exactly once. One evening, last year, for about three hours.
I was in the room. I didn't touch the keyboard, and I only answered questions when the session would otherwise have stopped. What I did instead was write things down: every hesitation, every time the person driving it went back on something they'd just done, every question from the room that the screen couldn't answer.
That notebook is, almost line for line, the changelog of version 2.
The app is Rueda de Actos, a Windows desktop app for the Filà Ligeros — one of the groups that take part in the Moros y Cristianos festival in Alcoy, Spain. Once a year it hands out participation in five festival events, eleven slots each, following a rotating turn that has to stay fair from one year to the next. The session happens in a meeting room, projected on a wall, with the board watching.
What I deliberately didn't do was ask
I could have sent a list of questions the next morning. It would have been faster and it would have produced a worse list.
If I'd asked what's missing, I'd have got three or four reasonable answers, because that's what the question invites: a considered opinion about the app in the abstract. The list I actually came home with had fifteen items on it, and the ones that mattered most were things nobody would have volunteered — in the moment they didn't register as missing features. They registered as this is just how it is.
The notebook had two columns. What the person did, and what they seemed to expect would happen. Only the gap between those two things was worth anything. A pause before a click is a gap. Going back to fix something a different way than the app offered is a gap. Somebody in the room asking a question that the screen was already answering, badly, is a gap.
Asking gets you opinions. Watching gets you requirements.
Here's what came out of it.
1. A closed event has to be re-openable
About ninety minutes in, on the fourth of the five events, somebody noticed that a name in event two shouldn't have been there.
Version 1 had exactly one answer for this: Restore by Event, which reloads the snapshot taken just before that event was verified. It works perfectly. It also erases events three and four, which had taken forty minutes and a fair amount of discussion to settle.
So it didn't get used. The correction was written on paper and applied later. Which means that from that moment on, the app was no longer the record of what had happened — the paper was.
Reopening a single event is easy to describe and slightly more interesting to implement than it looks, because closing an event doesn't only fill cells. It rotates people. Everyone who took part moves to the back of the queue, and that rotation is the entire fairness guarantee the app exists to provide.
The obvious fix — restore each person's previous queue number — is wrong. Their old neighbours have moved on:
// Someone pulled out of a reopened event does NOT get their old num_rueda
// back: the people who did go out are no longer ahead of them, and everyone
// has moved up a place. What they get is their relative position — behind the
// first of their former predecessors who still hasn't gone out. Number 7, with
// number 3 already rotated away, comes back as 6, which is what they're owed.
lista.splice(idxActual, 1)
let destino = 0
for (let i = idxPrevio - 1; i >= 0; i--) {
const anterior = lista.find(
(p) => p.nombre === state.ordenPrevioReabierto[i] && !haSalidoEsteAño(p)
)
if (anterior) {
destino = lista.indexOf(anterior) + 1
break
}
}
lista.splice(destino, 0, persona)
reasignarNumerosRueda()
ordenPrevioReabierto is just the list of names as it stood in the snapshot taken before that event closed. If the snapshot is gone, the person goes last among those who haven't been out yet — a worse placement, but the reopen still happens. Refusing to correct an error because a backup file is missing would be solving my problem, not theirs.
A year later, this is the feature I'd point at if somebody asked what watching bought me. It wasn't a missing button. It was a case where the app's only recovery mechanism was so expensive that the user quietly stopped using the app instead.
2. An event that wasn't full was showing as full
Skipping an event already existed in version 1. That wasn't the problem.
The problem was afterwards. A skipped event and one that closed with eight of eleven slots looked exactly like a full one — five identical green cards across the top, all reading as done.
That's not sloppiness in the view layer, it's information being destroyed at close time. The per-event counter gets clamped to the limit when an event is verified, because that's the flag for "played":
// Slots actually covered when each event closed; null = not closed yet.
// This needs its own record because `contadores` can't tell you: on close it
// gets pinned to the limit to mark the event as played, so a skipped event or
// one verified half-empty is indistinguishable from a full one.
plazasCubiertas: { escuadra1: null, escuadra2: null, diana1: null, diana2: null, diana3: null },
With that in place the card can tell the truth:
// Played, but without filling all 11 slots: skipped, or verified with fewer
// people. The rota carries on either way, so the card is the only place this
// is recorded.
function actoIncompleto(acto: ActoName): boolean {
const cubiertas = appState.plazasCubiertas[acto]
return actoFinalizado(acto)
&& cubiertas !== null
&& cubiertas < appState.limitePorColumna[acto]
}
It now goes amber and says Skipped or Incomplete, with the real count — 8/11 — and the status bar warns at the end of the rota how many closed short.
This matters more than a colour, because that history is the input to next year's turn. An event closed with eight people is not the same fact as one closed with eleven, and version 1 was quietly writing down the second when the first had happened. Reality doesn't fill every slot. Software that assumes it will ends up storing a slightly false year, then reasoning from it.
3. The spreadsheet they already had
Version 1 started from a JSON file. I know that file was fine, because I built it myself, out of their spreadsheet, on my machine.
Which is the whole problem. An app whose first screen needs a data migration performed by its developer is not a tool that group owns. It's a tool I own and let them use.
They had a spreadsheet. Every association has a spreadsheet.
So version 2 imports and exports both Excel and JSON, in both directions — and, the part that actually took the work, accepts a sheet somebody typed by hand. The only hard requirement is a column called Nombre.
Headers are matched after normalisation, and both the visible header and the internal field name are accepted:
// No accents, no case, whitespace collapsed: the header the app writes has
// accents and the one the user types may not, and "Género" and "genero" have
// to be the same column.
function normalizar(texto) {
return String(texto == null ? '' : texto)
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
}
Values are read the same way — H/M for gender, X or Sí for a checkbox, Cad. for an expired license, because that's what people actually put in cells. But there's one decision in there I'd defend harder than the rest:
const SI = new Set(['si', 'sí', 's', 'x', 'true', 'verdadero', '1'])
const NO = new Set(['no', 'n', 'false', 'falso', '0'])
// Unrecognised values come back untouched instead of becoming "No": the main
// process validates afterwards, and it's better for the user to see what they
// typed wrong than for the app to decide for them and silently change a fact.
function aSiNo(valor) {
const bruto = texto(valor)
if (bruto === '') return ''
const clave = normalizar(bruto)
if (SI.has(clave)) return 'Sí'
if (NO.has(clave)) return 'No'
return bruto
}
Being lenient about input and being lenient about meaning are different things. Reading x as yes costs nothing. Reading an unrecognised value as "No" would let a lenient importer write a false fact about a person, and nobody would ever find out.
The reciprocal fix was less glamorous: in version 1 the Excel the app exported could not be imported back. It generated a file it couldn't read. Nobody had complained, because nobody had tried, but a format that only goes one way isn't an export — it's a printout.
4. Nobody types accents in front of a room
The single most repeated moment of the evening was somebody looking for a name in a table of ninety-six rows, scrolling, while the room waited.
Version 2 has a search box. The whole feature is about fifteen lines, and the only part that matters is that it doesn't ask for precision:
// The names come in from the imported file in caps and with accents, so
// without normalising, searching "jose" wouldn't find "JOSÉ".
function normalizar(texto: string): string {
return texto.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()
}
Two smaller decisions came from the same three seconds of watching. Results are ordered by where the match starts, so people whose name begins with what you typed come first — when you type a first name, that's who you mean, even if the same string shows up inside somebody else's surname. And the highlight is sliced out of the original string, not the normalised one, so the accents and capitals survive on screen:
return hallados.slice(0, MAX_RESULTADOS).map(({ indice, persona, pos }) => {
const nombre = persona.nombre || ''
return {
indice,
persona,
antes: nombre.slice(0, pos),
coincide: nombre.slice(pos, pos + termino.length),
despues: nombre.slice(pos + termino.length),
}
})
None of this is clever. It's the kind of thing that gets skipped because it isn't a feature, it's a detail — and then it turns out to be the difference between finding a person in two seconds and scrolling a table in front of an audience.
5. Closing the app shouldn't cost you the evening
This one didn't come from a hesitation. It came from the app being closed with unsaved work in it.
The data model here is deliberately strict: the app always boots from the saved database, never from the working session. Save is the only point that consolidates anything. That's a real feature — it means closing without saving is a clean undo of the entire evening, which is exactly what you want after a session that went sideways.
It also means an accidental close is catastrophic, and an accidental close is not a hypothetical. It happened.
You can't fix that by relaxing the rule, because the rule is the feature. So every change to the working session also writes it somewhere separate:
// Single point every change to the working session passes through, so it's
// also where "there's something pending Save" gets marked.
function guardarEnSession(): void {
ipc.saveSessionPersonas(state.listaPersonas)
state.cambiosSinGuardar = true
}
That lands in its own SQLite table, next to the consolidated one:
CREATE TABLE IF NOT EXISTS session_data (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
timestamp TEXT
);
And Settings → Load temporary version (session) brings it back, with both timestamps shown side by side so you can see what you're choosing between. Closing the app now also asks, offering Save and exit / Exit without saving / Cancel — and if the save fails, it doesn't exit.
The general shape of this: if you make a strict rule about when data is committed, you owe the user a way back from the one case where that rule hurts. Not a softer rule. An escape hatch, clearly labelled as one, sitting outside the normal flow.
What I didn't build, and why
Fifteen items in the notebook. Not fifteen features. Some of the best decisions of version 2 were the ones I talked myself out of:
- Two people editing at once. This never came up as a request; it came up as a could it. The session has exactly one person driving, in a room, on purpose. That's what makes the outcome authoritative. Multi-user doesn't improve that evening, it dissolves it.
- Cloud sync or cloud backup. The app backs up its database locally every thirty minutes, which protects against nothing if the machine dies. The honest answer to "what if the laptop breaks" is export the file and put it somewhere else, and I ship that instead. Adding a server would also undo the entire offline-first argument the app is built on.
- A configurable number of events and slots. Genuinely the most tempting one, and it came from me rather than from the room — the generalisation itch. Five events, eleven slots, in that fixed order, is what this group does. Making it configurable buys a hypothetical second customer and charges every screen in the app a layer of indirection to get there.
- A general undo stack. Reopening an event covers the case that actually occurred. Ctrl+Z over a rotating queue is a much larger idea wearing a small idea's clothing.
- Auto-update. It checks nothing and connects nowhere. Updating means running the new installer over the old one, and that is the whole story.
Every one of those makes the app more capable in the abstract and worse at the single evening it exists for. Saying no to them is not restraint or minimalism, it's the same design work as saying yes to the other five — done in the direction nobody gives you credit for.
Version 1's job was to produce the list
The uncomfortable part is how cheap every one of these fixes was. A field that stores what the counter destroys. A normalise call. A second table. A placement loop that took an afternoon.
I could not have specified any of them up front, and I don't think a longer analysis phase would have got me there. I wrote version 1 from the rules of the rota — slots, rotation, squads, veterans, licenses, penalties — and every one of those rules was implemented correctly in version 1. It was a correct application. The list above isn't in the rules. It lives in the gap between the rules and one evening in a room in Alcoy.
So "a year of real use" is doing some work in that title, and I should be precise about it: one session run live, and twelve months of that session's result being the thing people actually consulted. Both halves produced findings. The evening produced the hesitations; the year afterwards produced the ones you only notice when you go back to the data and it doesn't say what happened.
Between them they produced a better requirements document than any process I could have run beforehand, and the cost was sitting still and writing things down.
If there's something to take from it, it isn't watch your users — everybody says that and nobody schedules it. It's narrower: version 1 doesn't have to be complete. It has to be good enough to be used for real, once, in the actual conditions. Then you have to be in the room, and you have to keep your hands off the keyboard.
Top comments (0)