<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Christian • ancer</title>
    <description>The latest articles on DEV Community by Christian • ancer (@ancer).</description>
    <link>https://dev.to/ancer</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3965198%2F9b8a4f0e-f78d-4238-89e1-0b08c446259e.png</url>
      <title>DEV Community: Christian • ancer</title>
      <link>https://dev.to/ancer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ancer"/>
    <language>en</language>
    <item>
      <title>What a Year of Real Use Changed About My App</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 23 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/what-a-year-of-real-use-changed-about-my-app-1634</link>
      <guid>https://dev.to/ancer/what-a-year-of-real-use-changed-about-my-app-1634</guid>
      <description>&lt;p&gt;Version 1 of this app was used for real exactly once. One evening, last year, for about three hours.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That notebook is, almost line for line, the changelog of version 2.&lt;/p&gt;

&lt;p&gt;The app is &lt;strong&gt;Rueda de Actos&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately didn't do was ask
&lt;/h2&gt;

&lt;p&gt;I could have sent a list of questions the next morning. It would have been faster and it would have produced a worse list.&lt;/p&gt;

&lt;p&gt;If I'd asked &lt;em&gt;what's missing&lt;/em&gt;, 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 &lt;em&gt;this is just how it is&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Asking gets you opinions. Watching gets you requirements.&lt;/p&gt;

&lt;p&gt;Here's what came out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A closed event has to be re-openable
&lt;/h2&gt;

&lt;p&gt;About ninety minutes in, on the fourth of the five events, somebody noticed that a name in event two shouldn't have been there.&lt;/p&gt;

&lt;p&gt;Version 1 had exactly one answer for this: &lt;em&gt;Restore by Event&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The obvious fix — restore each person's previous queue number — is wrong. Their old neighbours have moved on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Someone pulled out of a reopened event does NOT get their old num_rueda&lt;/span&gt;
&lt;span class="c1"&gt;// back: the people who did go out are no longer ahead of them, and everyone&lt;/span&gt;
&lt;span class="c1"&gt;// has moved up a place. What they get is their relative position — behind the&lt;/span&gt;
&lt;span class="c1"&gt;// first of their former predecessors who still hasn't gone out. Number 7, with&lt;/span&gt;
&lt;span class="c1"&gt;// number 3 already rotated away, comes back as 6, which is what they're owed.&lt;/span&gt;
&lt;span class="nx"&gt;lista&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idxActual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;destino&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;idxPrevio&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;anterior&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lista&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nombre&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ordenPrevioReabierto&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;haSalidoEsteAño&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;anterior&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;destino&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lista&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;anterior&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;lista&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;destino&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;reasignarNumerosRueda&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ordenPrevioReabierto&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. An event that wasn't full was showing as full
&lt;/h2&gt;

&lt;p&gt;Skipping an event already existed in version 1. That wasn't the problem.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;done&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;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":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Slots actually covered when each event closed; null = not closed yet.&lt;/span&gt;
&lt;span class="c1"&gt;// This needs its own record because `contadores` can't tell you: on close it&lt;/span&gt;
&lt;span class="c1"&gt;// gets pinned to the limit to mark the event as played, so a skipped event or&lt;/span&gt;
&lt;span class="c1"&gt;// one verified half-empty is indistinguishable from a full one.&lt;/span&gt;
&lt;span class="nx"&gt;plazasCubiertas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;escuadra1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;escuadra2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diana1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diana2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diana3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place the card can tell the truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Played, but without filling all 11 slots: skipped, or verified with fewer&lt;/span&gt;
&lt;span class="c1"&gt;// people. The rota carries on either way, so the card is the only place this&lt;/span&gt;
&lt;span class="c1"&gt;// is recorded.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;actoIncompleto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ActoName&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cubiertas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;appState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plazasCubiertas&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;acto&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;actoFinalizado&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cubiertas&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cubiertas&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;appState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limitePorColumna&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;acto&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It now goes amber and says &lt;em&gt;Skipped&lt;/em&gt; or &lt;em&gt;Incomplete&lt;/em&gt;, with the real count — &lt;code&gt;8/11&lt;/code&gt; — and the status bar warns at the end of the rota how many closed short.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The spreadsheet they already had
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;They had a spreadsheet. Every association has a spreadsheet.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;Nombre&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Headers are matched after normalisation, and both the visible header and the internal field name are accepted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// No accents, no case, whitespace collapsed: the header the app writes has&lt;/span&gt;
&lt;span class="c1"&gt;// accents and the one the user types may not, and "Género" and "genero" have&lt;/span&gt;
&lt;span class="c1"&gt;// to be the same column.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texto&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Diacritic}/gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Values are read the same way — &lt;code&gt;H&lt;/code&gt;/&lt;code&gt;M&lt;/code&gt; for gender, &lt;code&gt;X&lt;/code&gt; or &lt;code&gt;Sí&lt;/code&gt; for a checkbox, &lt;code&gt;Cad.&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;si&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sí&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;verdadero&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;falso&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;// Unrecognised values come back untouched instead of becoming "No": the main&lt;/span&gt;
&lt;span class="c1"&gt;// process validates afterwards, and it's better for the user to see what they&lt;/span&gt;
&lt;span class="c1"&gt;// typed wrong than for the app to decide for them and silently change a fact.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;aSiNo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bruto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bruto&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clave&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bruto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clave&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sí&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;NO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clave&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;bruto&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Being lenient about input and being lenient about &lt;em&gt;meaning&lt;/em&gt; are different things. Reading &lt;code&gt;x&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Nobody types accents in front of a room
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The names come in from the imported file in caps and with accents, so&lt;/span&gt;
&lt;span class="c1"&gt;// without normalising, searching "jose" wouldn't find "JOSÉ".&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NFD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Diacritic}/gu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two smaller decisions came from the same three seconds of watching. Results are ordered by where the match starts, so people whose name &lt;em&gt;begins&lt;/em&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hallados&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_RESULTADOS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;indice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nombre&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nombre&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;indice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;antes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nombre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;coincide&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nombre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;termino&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;despues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nombre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;termino&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Closing the app shouldn't cost you the evening
&lt;/h2&gt;

&lt;p&gt;This one didn't come from a hesitation. It came from the app being closed with unsaved work in it.&lt;/p&gt;

&lt;p&gt;The data model here is deliberately strict: the app always boots from the saved database, never from the working session. &lt;em&gt;Save&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;It also means an accidental close is catastrophic, and an accidental close is not a hypothetical. It happened.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Single point every change to the working session passes through, so it's&lt;/span&gt;
&lt;span class="c1"&gt;// also where "there's something pending Save" gets marked.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;guardarEnSession&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ipc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveSessionPersonas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listaPersonas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cambiosSinGuardar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lands in its own SQLite table, next to the consolidated one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;session_data&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;em&gt;Settings → Load temporary version (session)&lt;/em&gt; 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 &lt;em&gt;Save and exit&lt;/em&gt; / &lt;em&gt;Exit without saving&lt;/em&gt; / &lt;em&gt;Cancel&lt;/em&gt; — and if the save fails, it doesn't exit.&lt;/p&gt;

&lt;p&gt;The general shape of this: &lt;strong&gt;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.&lt;/strong&gt; Not a softer rule. An escape hatch, clearly labelled as one, sitting outside the normal flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't build, and why
&lt;/h2&gt;

&lt;p&gt;Fifteen items in the notebook. Not fifteen features. Some of the best decisions of version 2 were the ones I talked myself out of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two people editing at once.&lt;/strong&gt; This never came up as a request; it came up as a &lt;em&gt;could it&lt;/em&gt;. 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud sync or cloud backup.&lt;/strong&gt; 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 &lt;em&gt;export the file and put it somewhere else&lt;/em&gt;, and I ship that instead. Adding a server would also undo the entire offline-first argument the app is built on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A configurable number of events and slots.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A general undo stack.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-update.&lt;/strong&gt; It checks nothing and connects nowhere. Updating means running the new installer over the old one, and that is the whole story.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 1's job was to produce the list
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If there's something to take from it, it isn't &lt;em&gt;watch your users&lt;/em&gt; — everybody says that and nobody schedules it. It's narrower: &lt;strong&gt;version 1 doesn't have to be complete.&lt;/strong&gt; 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.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>ux</category>
      <category>softwareengineering</category>
      <category>programming</category>
    </item>
    <item>
      <title>Designing a UI That Gets Projected in Front of a Room</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 16 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/designing-a-ui-that-gets-projected-in-front-of-a-room-4kdc</link>
      <guid>https://dev.to/ancer/designing-a-ui-that-gets-projected-in-front-of-a-room-4kdc</guid>
      <description>&lt;p&gt;Almost every interface decision I make assumes a private screen. One person, their own monitor, their own data in front of them. It's such a safe assumption that it never gets written down anywhere.&lt;/p&gt;

&lt;p&gt;For the last few months I've been building a Windows desktop app called &lt;strong&gt;Rueda de Actos&lt;/strong&gt;, for the Filà Ligeros — one of the groups that take part in the Moros y Cristianos festival in Alcoy, Spain. Its job is to hand out participation in five festival events, eleven slots each, following a rotating turn that has to stay fair from one year to the next.&lt;/p&gt;

&lt;p&gt;It breaks that assumption completely.&lt;/p&gt;

&lt;p&gt;The app runs once a year, in a meeting room, projected on a wall, with the whole board watching. One person drives it. There are ninety-six names in the table on screen, and a good number of them belong to people sitting in that room.&lt;/p&gt;

&lt;p&gt;Every interesting design decision in this app came out of that one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The room is the spec
&lt;/h2&gt;

&lt;p&gt;Put the scenario next to the usual mental model and the assumptions fall over one by one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The usual assumption&lt;/th&gt;
&lt;th&gt;The actual session&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One user looking at the screen&lt;/td&gt;
&lt;td&gt;One user driving, a room reading over their shoulder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The data is about other people&lt;/td&gt;
&lt;td&gt;The data is about the audience&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A mistake costs a few seconds&lt;/td&gt;
&lt;td&gt;A mistake happens in front of everyone it affects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You can use the app again tomorrow&lt;/td&gt;
&lt;td&gt;The session happens once a year&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of that is exotic. It's a projector and a meeting, which describes a fair amount of internal software. The difference here is that the rows &lt;em&gt;are&lt;/em&gt; the audience — this is a group of neighbours and friends deciding who marches in which squad, and the tool is standing in the middle of it.&lt;/p&gt;

&lt;p&gt;So the brief stopped being "make this efficient to operate" and became something closer to "make this safe to project".&lt;/p&gt;

&lt;h2&gt;
  
  
  The block is public. The reason is private.
&lt;/h2&gt;

&lt;p&gt;Two things can make a person ineligible: unpaid dues and a standing penalty. Both are recorded per person. Neither one appears anywhere in the table.&lt;/p&gt;

&lt;p&gt;They live in the individual record, behind the pencil icon on the row, as two toggles next to the license status. That record opens for one person at a time, and in practice it gets opened when nobody has a reason to look.&lt;/p&gt;

&lt;p&gt;What the table shows instead is that the cell can't be clicked. That's it.&lt;/p&gt;

&lt;p&gt;The reason this works is that a blocked cell is deliberately ambiguous. Three different rules produce it, and they all render identically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Behind on dues, or carrying a penalty.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockedByStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unpaidDues&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;penalty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;activeColumn&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;assignedThisYear&lt;/span&gt;

&lt;span class="c1"&gt;// Already taking part in another event this year.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockedByAnotherEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowMultipleEvents&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;hasAnyEventThisYear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;assignedThisYear&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="c1"&gt;// Already did this same event in an earlier edition.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blockedByRepeat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowRepeatEvent&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;activeColumn&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;assignedThisYear&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;person&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

&lt;span class="c1"&gt;// One class. Three reasons. The cell never says which.&lt;/span&gt;
&lt;span class="nx"&gt;classes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cell-blocked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blockedByStatus&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;blockedByAnotherEvent&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;blockedByRepeat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that class is a single flat pattern, with nothing attached to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;td&lt;/span&gt;&lt;span class="nc"&gt;.cell-blocked&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;repeating-linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="m"&gt;-45deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;5px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;116&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;139&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;.13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;5px&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;not-allowed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The room sees that the rules were applied. The room does not learn who owes money.&lt;/p&gt;

&lt;p&gt;The temptation I had to talk myself out of was the tooltip. On any other desktop app I'd have added &lt;code&gt;title="Blocked: unpaid dues"&lt;/code&gt; without thinking — it's helpful, it's cheap, it's what a good interface does. Projected, it's the worst feature in the product: the same private fact, on a half-second delay, rendered wherever the mouse happens to be resting, at wall size.&lt;/p&gt;

&lt;p&gt;Worth being precise about where the line sits, because it isn't "hide every status". The avantcarga license — the paperwork some members need for certain events — is right there in the open, as a small coloured card next to each name and as a running count in the header: 32 valid, 13 expired, 51 none. Nobody minds. It's an administrative fact about a document.&lt;/p&gt;

&lt;p&gt;Money and penalties are a fact about a person, in a room full of people who know them. That's the line, and it isn't a technical one. It came from asking what would be uncomfortable to have on a wall, not from a data classification exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everyone has to stay oriented, not just the operator
&lt;/h2&gt;

&lt;p&gt;In a normal app, only the user needs to know where they are. Here, thirty people are following along without touching anything, and every question they can't answer for themselves becomes an interruption: &lt;em&gt;wait, which event are we on? how many are left? is that one full?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the state of the session is permanently on screen, not tucked behind the current interaction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Five cards across the top&lt;/strong&gt;, one per event, each showing &lt;code&gt;8/11&lt;/code&gt; and a men/women split (&lt;code&gt;H 8  M 0&lt;/code&gt;), with a fill bar. The event in progress is the blue one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The active column is tinted down the entire height of the table&lt;/strong&gt;, so a row halfway down the list still reads as belonging to the current event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A status line along the bottom&lt;/strong&gt;: how many people are in the rota, which event is in progress, and whether the squad has locked to a gender.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One small detail I like more than it probably deserves. When an event closes, its per-gender counters are reset internally, so leaving them on the card would show a confident &lt;code&gt;H 0  M 0&lt;/code&gt; for an event that had just filled eleven slots. The card swaps them for a state instead — &lt;em&gt;Completed&lt;/em&gt;, &lt;em&gt;Skipped&lt;/em&gt;, &lt;em&gt;Incomplete&lt;/em&gt;, &lt;em&gt;Pending verification&lt;/em&gt; — and only shows numbers while they still mean something.&lt;/p&gt;

&lt;p&gt;Nobody would catch that in a code review. Projected, a wrong number on a wall gets a question from the third row, and the session stops while somebody explains that the zero doesn't mean what it says.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you cover something, let them move it
&lt;/h2&gt;

&lt;p&gt;When the eleven slots fill, a verification modal opens listing the eleven people, with a checkbox for squad leaders and an × to remove anyone who shouldn't be there. It's the moment the room is paying most attention.&lt;/p&gt;

&lt;p&gt;It also covers about eleven rows of the table underneath — which is exactly when somebody asks whether so-and-so already went out last year.&lt;/p&gt;

&lt;p&gt;On a private screen the answer is trivial: close the dialog, look, open it again. In front of a room, that means throwing away a selection you just made while eleven people wait for you to rebuild it.&lt;/p&gt;

&lt;p&gt;So every modal in the app can be dragged out of the way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;INTERACTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button, a, input, select, textarea, label, [contenteditable]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onPointerDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INTERACTIVE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

  &lt;span class="c1"&gt;// The handle is the header. Small dialogs have no header, so anything&lt;/span&gt;
  &lt;span class="c1"&gt;// non-interactive works there.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contentEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.modal-header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

  &lt;span class="nf"&gt;measureBase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;pointerStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;offsetStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;dragging&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;contentEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;setPointerCapture&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pointerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// without this, dragging selects the dialog's text&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in there exist only because of the room.&lt;/p&gt;

&lt;p&gt;The first is that check against &lt;code&gt;INTERACTIVE&lt;/code&gt;. Dragging from a button would make the checkboxes in that verification list less than perfectly reliable to click, and a mis-click during verification is the one mistake you cannot make quietly.&lt;/p&gt;

&lt;p&gt;The second doesn't appear in that snippet: every offset is clamped against &lt;code&gt;window.innerWidth&lt;/code&gt; and &lt;code&gt;innerHeight&lt;/code&gt; before it's applied, so the dialog can't be dragged past the edge of the screen. This is a kiosk-shaped desktop app — no browser chrome, no page scroll, nothing to rescue a window that left the viewport. Get that wrong and the operator is closing a modal they can no longer see, blind, in front of everybody.&lt;/p&gt;

&lt;p&gt;The position also resets to centre on every open, rather than remembering where the last dialog was left. Where you dragged the previous dialog says nothing about what the next one is covering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interrupt for changes, not for confirmations
&lt;/h2&gt;

&lt;p&gt;The app has one dialog system, and the rule that decides how a message appears turned out to be a single line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A notice is a dialog that closes itself and expects no answer.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isNotice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;showConfirmButton&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;showCancelButton&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there's nothing to answer, it isn't allowed to take the screen. &lt;em&gt;Saved&lt;/em&gt;, &lt;em&gt;12 records imported&lt;/em&gt;, &lt;em&gt;Exported to XLSX&lt;/em&gt;, &lt;em&gt;Event reopened&lt;/em&gt; — corner of the screen, no backdrop, a thin bar draining to show how long it stays, gone in two seconds. The app underneath keeps working the whole time.&lt;/p&gt;

&lt;p&gt;Dimming an entire projected wall to announce "Saved" is absurd once you've watched it happen. It was also covering the modal that was in the middle of closing behind it.&lt;/p&gt;

&lt;p&gt;The interesting case is the one that still blocks.&lt;/p&gt;

&lt;p&gt;The squad rules say an event locks to a gender once six of the eleven go the same way. When that happens the app doesn't just note it — it un-assigns everyone of the other gender from the event, which rewrites rows the room is actively reading.&lt;/p&gt;

&lt;p&gt;That started life as a corner notice. It was wrong. The toast slid away after two seconds, the table simply changed, and people were left wondering what the operator had done.&lt;/p&gt;

&lt;p&gt;It's now the one message in the app allowed to take the whole screen: centred, no backdrop click, no Escape, and a single &lt;em&gt;Understood&lt;/em&gt; button somebody has to press before the session moves on. It names the rule that fired — &lt;em&gt;Men's event&lt;/em&gt; — and says plainly that the women have been un-assigned.&lt;/p&gt;

&lt;p&gt;The rule I ended up with: &lt;strong&gt;interrupt when the screen changed underneath the room, never to confirm something the operator did on purpose.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  At three metres, colour is the interface
&lt;/h2&gt;

&lt;p&gt;Nobody in the fourth row is reading a table cell. They're reading colour and position, and only leaning in when something looks off.&lt;/p&gt;

&lt;p&gt;So colour carries real information, and it's the only part of the visual design with a strict budget:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Green&lt;/strong&gt; — assigned in the current edition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hatched grey&lt;/strong&gt; — unavailable, reason unstated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One tint per past year&lt;/strong&gt; — &lt;code&gt;2023&lt;/code&gt; amber, &lt;code&gt;2024&lt;/code&gt; yellow, &lt;code&gt;2025&lt;/code&gt; blue, and so on. You can see how long ago someone last did an event without reading a single number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bold year&lt;/strong&gt; — that person was a squad leader that year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small coloured card beside the name&lt;/strong&gt; — license valid, expired, or missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else is grey. There is no colour in this UI that doesn't mean something, which is less a matter of taste than of bandwidth: the moment you use a tint for decoration, the room starts trying to decode it.&lt;/p&gt;

&lt;p&gt;The honest caveat: those license cards are colour-only, three images that differ in nothing but hue, with a &lt;code&gt;title&lt;/code&gt; carrying the exact status. That would not pass an accessibility review, and it's on the list. The rest of the palette is redundant — every tinted cell also contains the year as text, and blocked cells carry a hatch pattern as well as a fill — but the cards genuinely aren't, and writing this up is what made me notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  None of this was in the requirements
&lt;/h2&gt;

&lt;p&gt;Here's the part that bothers me a little.&lt;/p&gt;

&lt;p&gt;Every decision above is small and cheap. Not painting two fields. One CSS class covering three cases. A &lt;code&gt;pointerdown&lt;/code&gt; handler with an early return. A boolean choosing corner over centre.&lt;/p&gt;

&lt;p&gt;And not one of them was in anything I was given. The brief was the rules of the rota — slots, rotation, squads, veterans, licenses, penalties — and every one of those rules is in the app, correctly. You could implement the entire specification, pass every test, and still ship something that quietly puts a member's unpaid dues on a wall in front of them.&lt;/p&gt;

&lt;p&gt;They came from watching the previous version being used. Not from a demo and not from a call: somebody had been running this session for years, and the shape of that evening — the projector, the questions from the room, the window that had to be closed to check something — was the real requirements document. It just wasn't written like one.&lt;/p&gt;

&lt;p&gt;I don't have a repeatable process for extracting that, and I'm suspicious of anyone who claims one. The closest thing I have is a question I now ask early, before any of the interesting technical questions: &lt;strong&gt;where will this be running, and who else can see the screen?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Context of use is a functional requirement
&lt;/h2&gt;

&lt;p&gt;We're fairly disciplined about non-functional requirements when they have familiar names. Performance, accessibility, security — those get budgeted for.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where the screen is&lt;/em&gt; doesn't have a familiar name, so it gets skipped. And when it's skipped, what you ship is an interface that is entirely correct and still fails on the one night it's used, because you built for a desk and it ended up standing in front of a room.&lt;/p&gt;

&lt;p&gt;The rota is fair either way. The maths doesn't care where it runs. But the whole point of building this was that the group could watch the reasoning happen and stop having the argument — and that only works if the screen shows exactly what the room is entitled to see, and not one field more.&lt;/p&gt;

</description>
      <category>ux</category>
      <category>design</category>
      <category>electron</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building Offline-First Desktop Software in 2026</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 09 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/building-offline-first-desktop-software-in-2026-1cfp</link>
      <guid>https://dev.to/ancer/building-offline-first-desktop-software-in-2026-1cfp</guid>
      <description>&lt;p&gt;Every new app is drawn with a server in it.&lt;/p&gt;

&lt;p&gt;Accounts, an API, sync, a subscription tier. It gets decided before anyone asks the question that actually matters: where will this run, and who is standing in front of it?&lt;/p&gt;

&lt;p&gt;For the last few months I've been building a Windows desktop app called &lt;strong&gt;Rueda de Actos&lt;/strong&gt;. It's a custom tool for the Filà Ligeros, one of the groups that take part in the Moros y Cristianos festival in Alcoy, Spain. Its job is to hand out participation in five festival events, eleven spots each, following a rotating turn that has to stay fair year after year.&lt;/p&gt;

&lt;p&gt;It makes zero network calls. Not one. No accounts, no sync, no telemetry, no update check.&lt;/p&gt;

&lt;p&gt;That isn't nostalgia and it wasn't a shortcut. It's the answer the context gave.&lt;/p&gt;

&lt;h2&gt;
  
  
  The room decides the architecture
&lt;/h2&gt;

&lt;p&gt;Software gets used somewhere, and that somewhere usually has more to say about your stack than any benchmark does.&lt;/p&gt;

&lt;p&gt;Here is the somewhere: a room, a projector, the whole board sitting around a table, and one person driving the app while everyone watches the screen. It happens once a year. The wifi in that building is whatever it happens to be that night.&lt;/p&gt;

&lt;p&gt;Run a cloud app against that scenario and count the failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The connection drops on the one night of the year the tool is needed.&lt;/li&gt;
&lt;li&gt;Members' personal data leaves the building to sit on somebody else's server.&lt;/li&gt;
&lt;li&gt;The tool stops working the day a subscription lapses, or the day I stop maintaining it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are exotic. They are the ordinary price of having a server, and normally you pay it gladly, because you get sync, multi-user access, remote access and painless updates in return. In this room you get none of that back. There is one user, one machine, and one session a year.&lt;/p&gt;

&lt;p&gt;So the server came out. What's left is a database file on the computer of the person running the meeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shell&lt;/td&gt;
&lt;td&gt;Electron 31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Vue 3 (Composition API) + Bootstrap 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;SQLite via &lt;code&gt;better-sqlite3&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;Vite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;Vitest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packaging&lt;/td&gt;
&lt;td&gt;electron-builder → NSIS installer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logging&lt;/td&gt;
&lt;td&gt;electron-log, to a local file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing exotic. The interesting part is what the renderer is &lt;em&gt;allowed&lt;/em&gt; to do. It runs with &lt;code&gt;nodeIntegration&lt;/code&gt; off and &lt;code&gt;contextIsolation&lt;/code&gt; on, so its entire access to the machine is whatever the preload script decides to hand it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// electron/preload.js&lt;/span&gt;
&lt;span class="nx"&gt;contextBridge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exposeInMainWorld&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;getPersonas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ipcRenderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db:getPersonas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;savePersonas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ipcRenderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db:savePersonas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;importXLSX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ipcRenderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;import:xlsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;exportJSON&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ipcRenderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;export:json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI is a web app that cannot touch the filesystem or the database. It can call the handful of functions the preload hands it, and nothing else. Every one of them is validated on the other side before it reaches SQLite. Removing the server does not mean removing the boundary — it means the boundary moved inside the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  What disappears when you delete the server
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accounts and authentication.&lt;/strong&gt; Physical access to the laptop is the authentication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync and conflict resolution.&lt;/strong&gt; There is one writer. There is never a merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recurring cost.&lt;/strong&gt; Nothing to pay, nothing to renew, nothing to migrate when a provider changes its pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attack surface.&lt;/strong&gt; No listening port, no endpoint, no token to leak, no dependency on my uptime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The privacy conversation.&lt;/strong&gt; Not because it was answered well, but because it stopped applying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one was the part the client actually felt. "Where do the names end up?" — "On your computer. They never leave it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Three problems move in instead
&lt;/h2&gt;

&lt;p&gt;Deleting the server doesn't simplify the project. It relocates the difficulty.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Backups are your job now
&lt;/h3&gt;

&lt;p&gt;There is no nightly dump on somebody else's infrastructure. So the app makes its own: a full SQLite copy every 30 minutes, keeping the last ten.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;performBackup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;:.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;backupToFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backupDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`rueda-ligeros-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.db`&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="nf"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;backupDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_BACKUPS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// newest ten stay, the rest are unlinked&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;performBackup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;performBackup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;better-sqlite3&lt;/code&gt; exposes SQLite's own online backup API, so this is a consistent snapshot rather than a file copy taken mid-write.&lt;/p&gt;

&lt;p&gt;Worth being honest about what this protects against: those copies live on the same disk as the original. They save you from "I just broke the data", not from "the laptop died". The second one has a different answer, further down.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Migrations run on a machine you will never see
&lt;/h3&gt;

&lt;p&gt;There is no maintenance window, no staging environment, no rollback button, and nobody to call. The user double-clicks the new version and the schema has to be correct by the time the window appears.&lt;/p&gt;

&lt;p&gt;So the schema is versioned, and the app migrates itself on startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runMigrations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSchemaVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// from a metadata table&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;migrations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;currentVersion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;migration&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;migration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;up&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nf"&gt;setSchemaVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;migration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;})()&lt;/span&gt;
    &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Migration v&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;migration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; applied: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;migration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each migration is a &lt;code&gt;{ version, description, up }&lt;/code&gt; object in an ordered array, each one runs inside a transaction, and a throw stops the chain instead of leaving the database half-converted.&lt;/p&gt;

&lt;p&gt;That structure earned its keep on migration 3, which normalised the event assignments out of the &lt;code&gt;personas&lt;/code&gt; table into a &lt;code&gt;persona_actos&lt;/code&gt; child table. SQLite's answer to dropping columns is create-copy-drop-rename, which is well documented and looks harmless:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the new child table.&lt;/li&gt;
&lt;li&gt;Read the old assignments.&lt;/li&gt;
&lt;li&gt;Rebuild the parent table without the old columns.&lt;/li&gt;
&lt;li&gt;Insert the assignments into the child table.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Get steps 3 and 4 the wrong way round and you lose data silently. With &lt;code&gt;foreign_keys = ON&lt;/code&gt;, the &lt;code&gt;DROP TABLE personas&lt;/code&gt; in step 3 fires an implicit &lt;code&gt;DELETE&lt;/code&gt;, which cascades through &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; and empties the child table you just carefully filled. No error. No warning. An empty table, and a version number claiming the migration succeeded.&lt;/p&gt;

&lt;p&gt;The comment explaining that ordering is now four lines long and sits directly above the code. When a migration runs on a stranger's machine, in a building you're not in, the day before the tool is needed, there is no second attempt.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Distribution is a person carrying a file
&lt;/h3&gt;

&lt;p&gt;No auto-update. Not even a version check — a version check is a network call, and the whole premise was that there aren't any. The electron-builder config has no &lt;code&gt;publish&lt;/code&gt; block and never will: there is nowhere to publish to, and nothing to check against.&lt;/p&gt;

&lt;p&gt;New version means a new installer, run over the old one, data preserved. That is the entire update story.&lt;/p&gt;

&lt;p&gt;And then there's SmartScreen. The installer isn't code-signed, so Windows greets a non-technical user with a blue full-screen panel that says &lt;em&gt;"Windows protected your PC"&lt;/em&gt; and hides the button that runs it anyway behind a &lt;em&gt;More info&lt;/em&gt; link. To a developer that's an inconvenience. To the person installing it, that dialog reads as "this is a virus", and the software has failed before it ever opened.&lt;/p&gt;

&lt;p&gt;There are two options: buy a certificate, or explain. A code-signing certificate is a recurring cost, and even with one, SmartScreen reputation takes downloads to accumulate — which an app with a single-digit user count will never get. So the build script is written to sign automatically the day a certificate exists, and until then the answer is documentation: one line in the install guide, and a heads-up to the person who will click the button, before they click it.&lt;/p&gt;

&lt;p&gt;It's an unsatisfying answer. It's also the honest one, and pretending the dialog doesn't exist would have been worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data has to be able to leave
&lt;/h2&gt;

&lt;p&gt;With no server, there is no "your data is safe with us" to offer. What you can offer instead is that nothing is trapped.&lt;/p&gt;

&lt;p&gt;Everything imports and exports in both directions, as XLSX and as JSON. Import accepts the spreadsheet the group was already keeping — a hand-made one is fine, as long as there's a name column. Export writes a file that goes on a USB stick, into an email, or onto another machine.&lt;/p&gt;

&lt;p&gt;That's also the real backup story. The half-hourly copies protect the session; the export is the one that survives the hard drive. And it's the answer to the question every custom-software client should ask and rarely does: &lt;em&gt;what happens to us if you disappear?&lt;/em&gt; The data is a spreadsheet. Somebody else can pick it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing replaces the operations you don't have
&lt;/h2&gt;

&lt;p&gt;No error tracking, no logs streaming back to me, no ability to hotfix anything. Whatever ships is what runs until somebody tells me otherwise.&lt;/p&gt;

&lt;p&gt;What's left is testing before the fact: close to 400 tests covering the turn rotation, the eligibility rules, validation, the migrations and the import/export round trip, running on every build of the installer. Plus electron-log writing to a local file, so that when something does go wrong there's something concrete to ask the user for.&lt;/p&gt;

&lt;p&gt;That trade is easy to underestimate. Server-side, you can be wrong for twenty minutes. Here, being wrong means being wrong for a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline-first isn't a step back
&lt;/h2&gt;

&lt;p&gt;The reflex is to read a local desktop app as what we used to do before we knew better. Sometimes that's exactly what it is. Here it isn't.&lt;/p&gt;

&lt;p&gt;Everything the cloud would have added to this project — sync, accounts, remote access, seamless updates — is worth nothing in a room with one machine and one session a year. Everything it would have introduced — a connection that can fail, a bill that can lapse, personal data on somebody else's disk — lands squarely on the night it matters most.&lt;/p&gt;

&lt;p&gt;Picking an architecture is not picking the most current option. It's picking the one that still works under the real conditions of use.&lt;/p&gt;

&lt;p&gt;The wifi in that room can do whatever it likes. The app opens.&lt;/p&gt;

</description>
      <category>electron</category>
      <category>sqlite</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The First Version Is Just The Beginning</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 29 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/the-first-version-is-just-the-beginning-4mch</link>
      <guid>https://dev.to/ancer/the-first-version-is-just-the-beginning-4mch</guid>
      <description>&lt;p&gt;Releasing a piece of software feels like reaching the finish line. After weeks or months of planning, writing code, fixing bugs and polishing details, clicking the "Publish" button is a satisfying moment. But the more products I build, the more I realize that a release isn't the end of the journey. It's simply the point where the product stops being shaped only by the developer and starts being shaped by the people who actually use it.&lt;/p&gt;

&lt;p&gt;Before a launch, every design decision is based on assumptions. You can test your application, ask for opinions and imagine different workflows, but there's a limit to how much you can learn in isolation. Real users always find unexpected ways to use a product. They notice friction you had stopped seeing, ask questions you never considered and often reveal problems that only appear outside your own development environment.&lt;/p&gt;

&lt;p&gt;One thing I've learned is that software rarely improves because of one huge update. More often, progress comes from dozens of small refinements. A shortcut that saves a few seconds. A button that's easier to find. A clearer error message. A better default setting. None of those changes deserves a headline on its own, but together they have a significant impact on how the product feels after weeks or months of use.&lt;/p&gt;

&lt;p&gt;Feedback plays an important role in that process, although not always in the way people expect. Feature requests are useful, but they are only one type of feedback. Sometimes the most valuable comments aren't asking for something new—they're describing a moment of confusion or explaining why a workflow didn't behave as expected. Those observations often highlight opportunities to simplify the product rather than expand it.&lt;/p&gt;

&lt;p&gt;That's the mindset I've tried to keep while working on Convertim. Since its release, the objective hasn't been to add features as quickly as possible. Instead, I've focused on understanding how people use the application, improving existing workflows and being selective about what deserves to become part of the product. Every new feature increases the cost of maintenance, documentation and testing, so saying "not yet" is often as important as saying "yes."&lt;/p&gt;

&lt;p&gt;This approach isn't the fastest way to build software, but I believe it leads to better products in the long run. A tool that stays focused is usually easier to understand, easier to maintain and more enjoyable to use than one that tries to solve every possible problem. Growth is important, but clarity is what keeps a product useful over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Version 1.0 isn't the destination. It's the first opportunity to learn from real users and real workflows. The goal isn't to ship as many updates as possible, but to make each one meaningful. Looking back, I think that's what building software is really about: continuous improvement, thoughtful decisions and accepting that the first version is only the beginning.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>programming</category>
      <category>productivity</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Every Dependency Has a Cost</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Sat, 25 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/every-dependency-has-a-cost-19cm</link>
      <guid>https://dev.to/ancer/every-dependency-has-a-cost-19cm</guid>
      <description>&lt;p&gt;One of the best things about modern software development is that we don't have to build everything ourselves. Open-source libraries and third-party packages let us solve complex problems in minutes instead of days, allowing us to focus on what actually makes our applications unique.&lt;/p&gt;

&lt;p&gt;That's why adding a dependency often feels like an obvious choice.&lt;/p&gt;

&lt;p&gt;The problem is that every dependency comes with baggage.&lt;/p&gt;

&lt;p&gt;You're not only importing code—you are also importing someone else's release cycle, security updates, breaking changes, licensing decisions, and maintenance priorities. Even the most popular packages can become abandoned or introduce changes that force you to adapt your own project.&lt;/p&gt;

&lt;p&gt;That doesn't mean dependencies are bad. Many of them save hundreds of hours of work and have become industry standards for good reason. The key is being intentional instead of installing a package for every small problem.&lt;/p&gt;

&lt;p&gt;Before adding a new dependency, I usually ask myself a few simple questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does it solve a significant problem?&lt;/li&gt;
&lt;li&gt;Is the project actively maintained?&lt;/li&gt;
&lt;li&gt;Could I reasonably implement this myself?&lt;/li&gt;
&lt;li&gt;If I needed to remove it later, how difficult would that be?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions don't always lead to the same answer, but they help avoid unnecessary complexity.&lt;/p&gt;

&lt;p&gt;While building Convertim, I've found that saying &lt;strong&gt;"no"&lt;/strong&gt; to an extra dependency is sometimes the better engineering decision. A few more lines of code today can mean fewer updates, fewer compatibility issues, and less maintenance in the future.&lt;/p&gt;

&lt;p&gt;Architecture isn't only about choosing the right technologies.&lt;/p&gt;

&lt;p&gt;Sometimes it's also about choosing &lt;strong&gt;what not to add.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Features We Chose Not To Build</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/the-features-we-chose-not-to-build-2lde</link>
      <guid>https://dev.to/ancer/the-features-we-chose-not-to-build-2lde</guid>
      <description>&lt;p&gt;When people talk about software development, the conversation usually revolves around what gets added. New features generate excitement, screenshots, release notes and marketing material. They give users something tangible to explore and give developers something new to build. It's easy to believe that the value of a product grows in direct proportion to the number of capabilities it offers.&lt;/p&gt;

&lt;p&gt;In reality, some of the most important product decisions never appear in a changelog.&lt;/p&gt;

&lt;p&gt;Every feature starts as a good idea. Someone encounters a problem, imagines a solution and suggests adding another option, another setting or another workflow. Looking at those ideas one by one, it's surprisingly difficult to reject them because each proposal often makes perfect sense on its own. The challenge only becomes visible when you look at the product as a whole instead of evaluating individual requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every Feature Is a Long-Term Commitment
&lt;/h2&gt;

&lt;p&gt;Adding functionality isn't just about writing code. Every new feature becomes part of the application for as long as the product exists. It needs documentation, testing, maintenance and compatibility with everything that comes afterwards. Future updates have to take it into account, bug fixes become more complicated and every new release has one more moving part that can potentially fail.&lt;/p&gt;

&lt;p&gt;Those costs are rarely obvious when the feature is first implemented. At that moment, all you see is the problem it solves. The maintenance cost only becomes apparent months or even years later, when the product has evolved and dozens of independent decisions begin interacting with one another.&lt;/p&gt;

&lt;p&gt;That's why product development isn't simply a process of adding value. It's also a process of deciding which responsibilities you're willing to carry in the future.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Best Question Isn't "Can We Build It?"
&lt;/h2&gt;

&lt;p&gt;As developers, we naturally enjoy solving technical problems. When someone suggests a useful feature, the first instinct is often to think about how it could be implemented. Modern frameworks, libraries and development tools make building software faster than ever, which means the technical challenge is often smaller than it used to be.&lt;/p&gt;

&lt;p&gt;The more interesting question isn't whether something can be built. It's whether it deserves to become part of the product.&lt;/p&gt;

&lt;p&gt;Some ideas solve edge cases that affect very few people. Others introduce configuration screens that make the interface harder to understand. Some require new dependencies, increase testing effort or create maintenance work that continues long after the original problem has been forgotten.&lt;/p&gt;

&lt;p&gt;A feature can be technically successful while still making the product worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Convertim Meant Rejecting Good Ideas
&lt;/h2&gt;

&lt;p&gt;While building Convertim, there were plenty of ideas that looked genuinely useful. Some would have added flexibility, others would have supported additional workflows, and a few would probably have looked impressive on a feature list.&lt;/p&gt;

&lt;p&gt;We still decided not to build them.&lt;/p&gt;

&lt;p&gt;Not because they were impossible, but because they moved the application away from its main purpose. Convertim was designed to be a simple, local-first image conversion tool. Every feature that doesn't reinforce that goal has to justify the complexity it introduces, and many perfectly reasonable ideas simply couldn't pass that test.&lt;/p&gt;

&lt;p&gt;Saying "no" isn't about limiting the product. It's about protecting it from gradually becoming something it was never intended to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simplicity Doesn't Happen By Accident
&lt;/h2&gt;

&lt;p&gt;Software naturally becomes more complex over time. Every new option, every additional workflow and every extra dependency increases the number of decisions both developers and users have to make. Left unchecked, complexity accumulates almost automatically.&lt;/p&gt;

&lt;p&gt;Simplicity works differently.&lt;/p&gt;

&lt;p&gt;A simple product is usually the result of dozens of conscious decisions to leave things out. Those decisions often happen long before users ever download the application, and they're rarely visible from the outside. Users don't know which features were rejected during development, but they do notice when a tool feels focused, predictable and easy to understand.&lt;/p&gt;

&lt;p&gt;That's why simplicity should never be confused with a lack of effort. In many cases, it's the result of much more discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Think Beyond The Next Release
&lt;/h2&gt;

&lt;p&gt;It's tempting to evaluate features based on how exciting they are today. They make a roadmap look more impressive and provide something new to announce. The problem is that software doesn't stop evolving after the next release. Every decision becomes part of the foundation that future versions have to build upon.&lt;/p&gt;

&lt;p&gt;One mindset that has helped me is to imagine maintaining today's decisions a year from now. If a feature still feels worthwhile after considering future updates, bug fixes and ongoing maintenance, it's probably a good investment. If its long-term value is difficult to justify, adding it simply because it's possible is rarely the right answer.&lt;/p&gt;

&lt;p&gt;Thinking that way doesn't slow development down. It keeps the product moving in the right direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;A product isn't defined only by what it includes. It's also defined by everything its creators intentionally leave out.&lt;/p&gt;

&lt;p&gt;Users rarely ask whether a feature was difficult to build, but they immediately notice when an application feels focused, coherent and easy to use. Those qualities don't appear by chance. They're usually the result of hundreds of small decisions, many of which involve saying "no" to ideas that seemed perfectly reasonable at the time.&lt;/p&gt;

&lt;p&gt;Sometimes the best feature isn't the next one you add.&lt;/p&gt;

&lt;p&gt;It's the one you decide not to build.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>programming</category>
      <category>productivity</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why Software Friction Matters</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Sat, 18 Jul 2026 16:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/why-software-friction-matters-3iki</link>
      <guid>https://dev.to/ancer/why-software-friction-matters-3iki</guid>
      <description>&lt;p&gt;When we talk about improving software, the first idea is usually to add something new: another feature, another option, another way to customize the experience.&lt;/p&gt;

&lt;p&gt;But many times, the biggest improvements come from the opposite direction. Removing unnecessary steps, reducing decisions users should not have to make, and making common actions feel simpler.&lt;/p&gt;

&lt;p&gt;That is where software friction appears.&lt;/p&gt;

&lt;p&gt;Friction is everything that creates distance between the user and the result they want to achieve. It can be an extra step repeated hundreds of times, a confusing setting, a slow process, or a workflow that forces people to adapt to the tool instead of helping them complete their task.&lt;/p&gt;

&lt;p&gt;Individually, these problems usually look small. One extra click does not seem important. Waiting a few more seconds feels acceptable. Manually repeating an action once is not a big issue.&lt;/p&gt;

&lt;p&gt;But software is built around repetition, and small moments of friction become much more noticeable over time.&lt;/p&gt;

&lt;p&gt;Reducing friction is not only a design problem. It is also an engineering decision.&lt;/p&gt;

&lt;p&gt;A simple experience often depends on things users never directly see: how the application is structured, how workflows are designed, which defaults are selected, and which features are intentionally kept simple.&lt;/p&gt;

&lt;p&gt;The goal is not always to add more possibilities. Sometimes the better decision is making the existing workflow clearer, faster, and easier to understand.&lt;/p&gt;

&lt;p&gt;Good software does not just solve a problem. It removes everything unnecessary between the user and that solution.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What is a small improvement in a tool that completely changed your experience using it?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>software</category>
      <category>productivity</category>
      <category>softwareengineering</category>
      <category>ux</category>
    </item>
    <item>
      <title>Why Convertim Uses One Binary For GUI And CLI</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 15 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/why-convertim-uses-one-binary-for-gui-and-cli-4cj</link>
      <guid>https://dev.to/ancer/why-convertim-uses-one-binary-for-gui-and-cli-4cj</guid>
      <description>&lt;p&gt;When building Convertim, one of the early technical decisions was how different workflows should be supported. Some users prefer opening an application, dragging a few files, choosing an output format and pressing a button. Others prefer running commands, automating repetitive tasks or integrating tools into existing workflows.&lt;/p&gt;

&lt;p&gt;Both approaches solve the same problem, but they represent different ways of interacting with software. The challenge was deciding whether those workflows should become separate tools or different ways to use the same application.&lt;/p&gt;

&lt;h2&gt;
  
  
  The initial decision
&lt;/h2&gt;

&lt;p&gt;A common solution would have been creating two independent tools: a desktop application focused on visual interaction and a command-line tool focused on automation. This approach works well in many projects because each tool can evolve independently and focus on a specific type of user.&lt;/p&gt;

&lt;p&gt;While designing Convertim, we looked at it from a different perspective. Were we actually building two different products, or were we creating two different ways to access the same functionality?&lt;/p&gt;

&lt;p&gt;That question influenced the internal architecture. Instead of treating the graphical interface and the command line as separate projects, we decided to keep them as different entry points connected to the same core.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separating interface from logic
&lt;/h2&gt;

&lt;p&gt;One of the most important ideas behind this decision was separating the user interface from the actual processing logic. The conversion engine, file handling, validation and configuration should not depend on whether someone interacts with the application visually or through a terminal.&lt;/p&gt;

&lt;p&gt;The graphical interface and the command line are simply different layers built around the same foundation. They receive different types of input, but the core responsible for processing those actions remains shared.&lt;/p&gt;

&lt;p&gt;This separation means improvements made internally benefit both workflows. Changes to conversion logic, optimizations or bug fixes do not need to be implemented multiple times because both experiences rely on the same underlying system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoiding duplicated implementations
&lt;/h2&gt;

&lt;p&gt;Duplicating functionality can sometimes feel like the fastest solution, especially during the early stages of a project. Creating a separate CLI implementation alongside the desktop application would probably have worked initially.&lt;/p&gt;

&lt;p&gt;The problem usually appears later, when both versions need to evolve. A bug fixed in one implementation needs to be fixed again somewhere else. A new option has to be added twice. Small differences slowly appear between tools that are expected to behave consistently.&lt;/p&gt;

&lt;p&gt;Keeping a shared foundation helps reduce those problems. Not because having less code automatically means having better software, but because reducing duplicated logic usually makes a project easier to maintain over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supporting different workflows
&lt;/h2&gt;

&lt;p&gt;A graphical interface works well when direct interaction is the goal. Opening an application, selecting files and visually adjusting options can be the fastest and most comfortable experience for many users.&lt;/p&gt;

&lt;p&gt;But not every workflow works that way. Some tasks are repetitive by nature. Some users need to process many files frequently or include conversion steps inside scripts and automated processes.&lt;/p&gt;

&lt;p&gt;In those situations, forcing every interaction through a graphical interface creates unnecessary friction. The goal of adding CLI support was not to replace the GUI, but to provide another way to interact with the same tool depending on the context.&lt;/p&gt;

&lt;p&gt;Sometimes clicking a button is the best experience. Sometimes running a command is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The technical trade-offs
&lt;/h2&gt;

&lt;p&gt;Keeping GUI and CLI together also introduces some challenges. A shared foundation requires clearer boundaries between different parts of the application because multiple interfaces depend on the same internal behavior.&lt;/p&gt;

&lt;p&gt;Features need to be designed considering more than one interaction model. A change is not only a visual decision anymore; it can affect both manual and automated workflows.&lt;/p&gt;

&lt;p&gt;Like most engineering decisions, this approach is a trade-off. Separate applications provide more independence, while a shared core provides more consistency and reduces duplicated work.&lt;/p&gt;

&lt;p&gt;There is no universal answer. The right decision depends on the goals and constraints of each project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building around workflows
&lt;/h2&gt;

&lt;p&gt;For Convertim, keeping GUI and CLI inside the same application was not about adding another feature. It was about designing the application around the idea that different users can have different workflows without needing completely different tools.&lt;/p&gt;

&lt;p&gt;The visible part of software is usually the interface, but many of the decisions that define the experience happen much deeper in the architecture.&lt;/p&gt;

&lt;p&gt;Good architecture often stays invisible.&lt;/p&gt;

&lt;p&gt;And that usually means it is doing its job.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>cli</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Good Software Adapts To People</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Sat, 11 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/good-software-adapts-to-people-542l</link>
      <guid>https://dev.to/ancer/good-software-adapts-to-people-542l</guid>
      <description>&lt;p&gt;When we build software, it's easy to assume there is a correct way to use it. We design interfaces, create workflows and make decisions based on how we imagine people will interact with what we are building.&lt;/p&gt;

&lt;p&gt;But once a tool reaches real users, things are usually much more diverse.&lt;/p&gt;

&lt;p&gt;Different people have different habits, requirements and ways of solving problems. Someone may need a simple interface to complete a task once in a while, while someone else may need to automate that same process hundreds of times as part of a larger workflow.&lt;/p&gt;

&lt;p&gt;Neither approach is better than the other. They are simply different ways of working.&lt;/p&gt;

&lt;p&gt;This is why flexibility is an important part of product design. Giving users different ways to interact with a tool is not about adding complexity. When done correctly, it's about removing friction.&lt;/p&gt;

&lt;p&gt;A graphical interface can make a task more accessible and easier to understand. A command-line interface can make the same task easier to automate and integrate into existing processes.&lt;/p&gt;

&lt;p&gt;Both exist for the same reason: helping people solve a problem in the way that makes the most sense for them.&lt;/p&gt;

&lt;p&gt;Good software should not force everyone into the same workflow. It should provide a clear solution while respecting that different users have different needs.&lt;/p&gt;

&lt;p&gt;Sometimes the best design decisions are not the ones that change how people work, but the ones that allow them to keep working naturally.&lt;/p&gt;

&lt;p&gt;Technology should adapt to people, not the other way around.&lt;/p&gt;

&lt;p&gt;What about you? Has a tool ever changed the way you work for the better, or did it force you to adapt to it?&lt;/p&gt;

</description>
      <category>software</category>
      <category>productivity</category>
      <category>programming</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Designing Software For Different Workflows</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 08 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/designing-software-for-different-workflows-1hnp</link>
      <guid>https://dev.to/ancer/designing-software-for-different-workflows-1hnp</guid>
      <description>&lt;p&gt;When we build a tool, it is easy to assume there is a correct way to use it. We design an interface, define an ideal flow, and think about how someone should interact with our product.&lt;/p&gt;

&lt;p&gt;But when software reaches real users, a much more interesting reality appears: everyone has their own way of working.&lt;/p&gt;

&lt;p&gt;One person may need to convert a single image before sending it by email or uploading it to a website. Another may process hundreds of files as part of a repetitive workflow. Some people prefer a graphical interface because it feels fast and visual, while others work from a terminal because they need automation and integration with other processes.&lt;/p&gt;

&lt;p&gt;None of these approaches are wrong. They simply solve different needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the balance between simplicity and flexibility
&lt;/h2&gt;

&lt;p&gt;One of the biggest challenges when designing software is finding the right balance between keeping the experience simple and providing enough flexibility to support different scenarios.&lt;/p&gt;

&lt;p&gt;A simple tool should not become a limited tool. At the same time, a powerful tool should not force every user to deal with complexity they may never need.&lt;/p&gt;

&lt;p&gt;This was one of the ideas we kept in mind while building Convertim. For many users, converting an image should be a straightforward process: open the application, select files, choose a format, and get the result.&lt;/p&gt;

&lt;p&gt;No unnecessary configuration. No additional steps. No need to understand how the tool works internally.&lt;/p&gt;

&lt;p&gt;But there are situations where that same approach is no longer enough. If someone needs to convert large amounts of images regularly or integrate that process into another system, manually repeating the same steps stops being efficient.&lt;/p&gt;

&lt;p&gt;The need changes, and the tool should be able to adapt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Graphical interfaces and command lines solve different problems
&lt;/h2&gt;

&lt;p&gt;Graphical interfaces and command-line tools are often treated as opposite approaches, but they are actually designed for different contexts.&lt;/p&gt;

&lt;p&gt;A graphical interface lowers the barrier to entry. It allows someone to use a tool without reading documentation, remembering commands, or understanding technical details.&lt;/p&gt;

&lt;p&gt;A command-line interface, on the other hand, enables different possibilities: automation, scripts, repetitive processes, and integration into larger workflows.&lt;/p&gt;

&lt;p&gt;The important question should not be which option is better.&lt;/p&gt;

&lt;p&gt;The question should be what the person using the tool actually needs.&lt;/p&gt;

&lt;p&gt;A designer preparing images for a project and a developer processing them automatically may be solving exactly the same problem, but from completely different contexts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automation is also part of the user experience
&lt;/h2&gt;

&lt;p&gt;Automation is sometimes associated with complex tools designed only for technical users, but its goal is usually the opposite: reducing unnecessary work.&lt;/p&gt;

&lt;p&gt;Automation means removing repetitive actions so people can spend less time on mechanical tasks.&lt;/p&gt;

&lt;p&gt;A good tool should allow users to start with something simple and provide more possibilities only when they become necessary. Not everyone needs access to every option from the beginning, and adding flexibility does not mean increasing the complexity of the main experience.&lt;/p&gt;

&lt;p&gt;It means allowing each person to find the workflow that best fits their needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  More features do not always create a better product
&lt;/h2&gt;

&lt;p&gt;When developing software, it is tempting to measure the evolution of a product by the number of features it includes.&lt;/p&gt;

&lt;p&gt;More options. More settings. More possibilities.&lt;/p&gt;

&lt;p&gt;But every new feature also introduces new decisions, more maintenance, and greater responsibility over the complete experience.&lt;/p&gt;

&lt;p&gt;The most important question is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can we add this?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What problem are we solving?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Flexibility should be the result of understanding different use cases, not simply accumulating features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Software should adapt to people
&lt;/h2&gt;

&lt;p&gt;Every user has different habits, preferences, and needs. Trying to force everyone into a single workflow can end up creating more problems than it solves.&lt;/p&gt;

&lt;p&gt;Good software should not force people to completely change the way they work.&lt;/p&gt;

&lt;p&gt;It should fit naturally into it.&lt;/p&gt;

&lt;p&gt;Because in the end, the best tools are often the ones that stop getting in the way and simply help people do what they need to do.&lt;/p&gt;




&lt;p&gt;This article is part of the journey of building products at &lt;strong&gt;ancer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Convertim is our first public tool: a local-first image converter focused on simplicity, privacy and flexible workflows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ancer.es/en/project/convertim" rel="noopener noreferrer"&gt;https://ancer.es/en/project/convertim&lt;/a&gt;&lt;/p&gt;

</description>
      <category>software</category>
      <category>productdesign</category>
      <category>programming</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Shipping Is Just the Beginning</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Sat, 04 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/shipping-is-just-the-beginning-nam</link>
      <guid>https://dev.to/ancer/shipping-is-just-the-beginning-nam</guid>
      <description>&lt;p&gt;When you've spent months working on a project, it's easy to believe that launch day is the most important milestone. For a long time, everything revolves around that date: finishing tasks, fixing last-minute bugs, preparing the website, taking screenshots, and polishing the documentation. It all seems to lead to one goal: shipping.&lt;/p&gt;

&lt;p&gt;Then the day finally arrives. You hit the publish button, the project is live, and there's a real sense of excitement and relief. But the next morning, you realize that almost nothing has actually ended.&lt;/p&gt;

&lt;p&gt;That's when the most interesting part begins.&lt;/p&gt;

&lt;p&gt;People start using your software in ways you never expected. Small details that went unnoticed during development suddenly become obvious. Feedback starts coming in, and some of the decisions that once felt final are worth revisiting.&lt;/p&gt;

&lt;p&gt;Over time, you realize that great products don't evolve only through big new features. More often, they improve through small, thoughtful changes: fixing a bug, simplifying a workflow, or removing a tiny piece of friction from the user experience. Those improvements may seem minor, but they're often the ones users appreciate the most.&lt;/p&gt;

&lt;p&gt;That's why every launch is really the beginning of a conversation with the people using your product. Listening, learning, and continuously improving are just as much a part of software development as writing code. Releasing an application is an important milestone, but maintaining it and helping it evolve is what truly turns it into a product.&lt;/p&gt;

&lt;p&gt;The most interesting part starts after release.&lt;/p&gt;

</description>
      <category>software</category>
      <category>productivity</category>
      <category>programming</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>GUI or CLI? Why We Built Both</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Sat, 27 Jun 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/gui-or-cli-why-we-built-both-5mn</link>
      <guid>https://dev.to/ancer/gui-or-cli-why-we-built-both-5mn</guid>
      <description>&lt;p&gt;Software developers love debating interfaces.&lt;/p&gt;

&lt;p&gt;Some people will always prefer a graphical interface, while others feel most productive inside a terminal window. The interesting part is that both groups are usually trying to solve exactly the same problem—they just have different workflows.&lt;/p&gt;

&lt;p&gt;While building Convertim, we quickly realized that choosing one approach over the other would unnecessarily limit the tool.&lt;/p&gt;

&lt;p&gt;For many users, drag and drop is simply the fastest way to work. Open the application, select a folder, choose the output format and click a button. No commands to remember, no documentation to read, and no setup required.&lt;/p&gt;

&lt;p&gt;But developers and power users often have completely different needs.&lt;/p&gt;

&lt;p&gt;They want to integrate image conversion into scripts, CI pipelines, automation tasks or batch jobs that run without human interaction. For them, opening a graphical application would only slow things down.&lt;/p&gt;

&lt;p&gt;Instead of deciding which audience was "right", we decided to support both.&lt;/p&gt;

&lt;p&gt;The graphical interface focuses on simplicity and discoverability, making common tasks accessible with just a few clicks. The command-line interface, on the other hand, exposes the same processing engine in a way that can be scripted and automated.&lt;/p&gt;

&lt;p&gt;Neither interface is better than the other.&lt;/p&gt;

&lt;p&gt;They simply solve different problems.&lt;/p&gt;

&lt;p&gt;Good software should adapt to the user's workflow instead of forcing the user to adapt to the software. Whether someone prefers clicking buttons or writing commands shouldn't change the capabilities they have available.&lt;/p&gt;

&lt;p&gt;In the end, both interfaces share the same goal: making image conversion as simple as possible while staying fast, local and predictable.&lt;/p&gt;

&lt;p&gt;Sometimes the best engineering decision isn't choosing between GUI and CLI.&lt;/p&gt;

&lt;p&gt;It's refusing to make users choose.&lt;/p&gt;

&lt;p&gt;What about you? When a tool offers both options, which one do you reach for first: the GUI or the terminal?&lt;/p&gt;

</description>
      <category>cli</category>
      <category>productivity</category>
      <category>softwareengineering</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
