<?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>Shipping Desktop Software to People Who Will Never Read the Docs</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 14 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/shipping-desktop-software-to-people-who-will-never-read-the-docs-5c16</link>
      <guid>https://dev.to/ancer/shipping-desktop-software-to-people-who-will-never-read-the-docs-5c16</guid>
      <description>&lt;p&gt;Rueda de Actos has a README. It's long, half technical documentation and half user manual, and it's accurate: the data model, every feature, where the backups live, what to do after a bad close.&lt;/p&gt;

&lt;p&gt;Nobody who uses the app will ever read it.&lt;/p&gt;

&lt;p&gt;That isn't a complaint about users. It's the most important design constraint the app has, and it took me two versions to treat it as one.&lt;/p&gt;

&lt;p&gt;Rueda de Actos is the Windows desktop app I built for a festival group in Alcoy, Spain: five events, eleven spots each, and a rotating turn that has to stay fair year after year. The person who runs it is a volunteer on the group's board. They aren't technical, they open the app once a year, and on the night that matters there's no store to reinstall from, no support line and nobody to call. For most of the year I'm not in the room.&lt;/p&gt;

&lt;p&gt;So every instruction that matters has to be somewhere they'll actually look, at the moment they need it. This is the last article in the series about this app, and it's about the unglamorous part: getting software into the hands of people who will never read the docs, and keeping it working there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before the app exists: the installer
&lt;/h2&gt;

&lt;p&gt;The first moment is the only one where the app can't speak for itself, because it isn't running yet.&lt;/p&gt;

&lt;p&gt;The installer is a standard Windows setup wizard, in Spanish, the language of the people who use it. And it isn't code-signed, so the first time it runs, Windows shows a blue warning: &lt;em&gt;Windows protected your PC&lt;/em&gt;. The button that runs it anyway is hidden behind a &lt;em&gt;More info&lt;/em&gt; link.&lt;/p&gt;

&lt;p&gt;I went through the economics of a signing certificate in &lt;a href="https://dev.to/ancer/building-offline-first-desktop-software-in-2026-1cfp"&gt;an earlier article&lt;/a&gt;. What matters here is what that screen does to someone who isn't technical. It reads as "this is a virus", and the software has failed before it has opened.&lt;/p&gt;

&lt;p&gt;Nothing inside the app can fix that. So the explanation travels with the file: before anyone double-clicks, the person who will install it is told that a blue warning is coming and exactly which two things to press, in the words Windows will show them, &lt;em&gt;Más información&lt;/em&gt; and then &lt;em&gt;Ejecutar de todas formas&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The difference is all in the timing. A warning you didn't mention looks like a problem. A warning you predicted looks like proof that the instructions were right, and that buys trust for everything that comes after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first screen: an empty state that says what to do
&lt;/h2&gt;

&lt;p&gt;The database is created empty. There's no demo data and no sample list. An earlier build seeded the table from a bundled file, and it turned out to fire whenever the table was empty, not only on the first run, so it went.&lt;/p&gt;

&lt;p&gt;An empty table is a dead end, though. A grid of headers with nothing under it doesn't tell you what to do next. So when there's nobody in the list, the table isn't shown at all. In its place, translated from Spanish:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No members yet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Load the full list with &lt;strong&gt;Import&lt;/strong&gt;, in the header, or add them one by one with &lt;strong&gt;Add member&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The event buttons stay disabled until there's someone to choose from. In an earlier build they didn't, and starting an event on an empty list produced empty backups and blocked the rota.&lt;/p&gt;

&lt;p&gt;An empty state is the first page of the manual, and it's the only page that's guaranteed to be read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the data in: their spreadsheet, and errors that name the row
&lt;/h2&gt;

&lt;p&gt;Every association already has a spreadsheet. The alternative to importing it is typing everyone in by hand, which is how a tool dies before its first use.&lt;/p&gt;

&lt;p&gt;So &lt;em&gt;Import&lt;/em&gt; takes the spreadsheet the group already had, Excel or JSON, with a single condition: the first row holds the headers, and one of them is &lt;em&gt;Nombre&lt;/em&gt;. Headers are matched with or without accents, by their visible or internal name, abbreviations are accepted, and columns the app doesn't know are ignored. The details of that leniency are in &lt;a href="https://dev.to/ancer/what-a-year-of-real-use-changed-about-my-app-1634"&gt;the September article on real use&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What matters for people who won't read the docs is what happens when the file is wrong. If any record fails validation, the whole import is rejected, the data already in the app isn't touched, and the message says how many records failed and which rows, with the reason for each.&lt;/p&gt;

&lt;p&gt;All or nothing is deliberate. A failed import is obvious. A half-done import looks exactly like a list, and nobody counts the rows to check that they all arrived.&lt;/p&gt;

&lt;p&gt;And then the step that would be in bold in any manual: after importing, you have to press &lt;em&gt;Save&lt;/em&gt;. Which brings me to the one rule in this app that has to be impossible to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  One button that makes it real
&lt;/h2&gt;

&lt;p&gt;The data model is strict on purpose. The app always starts from the last saved state, and &lt;em&gt;Save&lt;/em&gt; is the only thing that commits anything. Closing without saving is a clean undo of the whole evening, which is exactly what you want after a session that went sideways.&lt;/p&gt;

&lt;p&gt;A rule like that is invisible unless the interface shows it, so it shows it all the time. The status bar along the bottom always says one of two things, &lt;em&gt;Saved&lt;/em&gt; or &lt;em&gt;Unsaved changes&lt;/em&gt;, and hovering the second one tells you to press &lt;em&gt;Save&lt;/em&gt;. The comment above that element is the whole argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- What isn't saved is lost on close, so the state has to be on screen at
     all times, not just in the exit warning. --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit warning exists too. Closing with something pending asks first, with three buttons: &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;. Clicking outside doesn't dismiss it. And if saving fails, the app stays open:&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;// If Save fails, we don't carry on: that would be exactly the case where it hurts most.&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isConfirmed&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;guardarDatos&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isDenied&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the close that happens anyway (the wrong button, a flat battery, someone tidying up the cables), there's an escape hatch: &lt;em&gt;Settings → Data management → Load temporary version (session)&lt;/em&gt; brings back the last thing that was on screen, saved or not, with the date of each version next to it.&lt;/p&gt;

&lt;p&gt;None of that is documentation in the usual sense. But the status bar, the three buttons and the recovery option explain the data model better than any paragraph could, and they do it at the only moments the user is thinking about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups that live on the same disk
&lt;/h2&gt;

&lt;p&gt;The app backs itself up: a full copy of the database every thirty minutes, keeping the last ten. It's a real safety net, for one specific failure: &lt;em&gt;I've just broken the data&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;It does nothing for the other one, &lt;em&gt;the laptop died&lt;/em&gt;, because those copies sit on the same disk as the original. I said that in &lt;a href="https://dev.to/ancer/building-offline-first-desktop-software-in-2026-1cfp"&gt;the offline-first article&lt;/a&gt;, and it bears repeating, because automatic backups are exactly the kind of feature that makes people stop worrying, and this is the one case where they should keep worrying.&lt;/p&gt;

&lt;p&gt;What actually protects the group's history is an export saved somewhere else: a USB stick, an external drive, an email. JSON and Excel both re-import without losing anything, so with that file a brand-new installation gets everything back through the same &lt;em&gt;Import&lt;/em&gt; as on the first day.&lt;/p&gt;

&lt;p&gt;The trouble is that nobody thinks about this until it's too late, and nobody reads the paragraph telling them to. So the export sits where the data is worth the most. The final summary, the screen that appears when the fifth event closes, has the export buttons on it, and the highlighted one is the JSON export. At that moment the evening's work is done, someone is still at the keyboard, and a copy is one click away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updating without losing a year
&lt;/h2&gt;

&lt;p&gt;The database doesn't live next to the program. It's in the user's Windows profile, so installing a new version over the old one never touches it. What does change is its shape, and the database a new version finds is, by design, a year old.&lt;/p&gt;

&lt;p&gt;So the schema is versioned. Every change to it ships as a migration that runs on first launch, each one inside a transaction, so a failure stops the chain instead of leaving the database half converted. The migrations that move existing data have their own tests, because the database they will meet on that machine is the real one, with years of history and no copy on any server. I wrote about how those tests work in &lt;a href="https://dev.to/ancer/testing-when-you-only-get-one-shot-a-year-3605"&gt;the testing article&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;From the user's side, all of that has to collapse into a sentence they already believe without being told: the new version keeps my data.&lt;/p&gt;

&lt;h2&gt;
  
  
  A year later: the guide
&lt;/h2&gt;

&lt;p&gt;The hardest user to design for is the same person, twelve months later. They used the app once, last year, and have forgotten all of it: where &lt;em&gt;Import&lt;/em&gt; is, what the amber card means, why some cells are striped.&lt;/p&gt;

&lt;p&gt;For that person, the app carries its own guide, one click away inside the app: nineteen short numbered sections, from managing members and choosing people for an event to verifying or skipping, reopening, the gender rules, squad leaders, search, saving and recovering, export and import, the final summary and how windows and notices behave.&lt;/p&gt;

&lt;p&gt;A few rules make it usable rather than merely complete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It uses the screen's words.&lt;/strong&gt; Every button is named exactly as it appears, in bold, and every path is the real one: &lt;em&gt;Settings → Data management → Load temporary version (session)&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each section is a handful of short sentences.&lt;/strong&gt; Nobody reads a guide from the top. People scan it for the one thing they're stuck on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It changes with the app.&lt;/strong&gt; When version 2 added search, reopening and the new event states, the guide grew in the same release. A feature isn't finished until its section is written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A guide inside the app has one advantage over every other kind of documentation: it's the only one guaranteed to still be there, on the right machine, a year from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interface is the documentation
&lt;/h2&gt;

&lt;p&gt;Look at where the instructions in this app actually live: a heads-up that travels with the installer, an empty state, an error that names the rows, a status bar, a dialog with three buttons, the export buttons on the last screen of the night, and a guide written in the words of the screen.&lt;/p&gt;

&lt;p&gt;That's the documentation. For someone who isn't technical, the documentation that exists is the interface itself. Everything else, the README, the FAQ, this article, gets read by people who no longer need it.&lt;/p&gt;

&lt;p&gt;This is the last article I'll write about Rueda de Actos for a while. The case study, with screenshots made from test data, is on my site: &lt;a href="https://ancer.es/en/project/rueda-ligeros" rel="noopener noreferrer"&gt;ancer.es/en/project/rueda-ligeros&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>electron</category>
      <category>windows</category>
      <category>ux</category>
    </item>
    <item>
      <title>What Happened When My App Finally Met the Room</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/what-happened-when-my-app-finally-met-the-room-2cen</link>
      <guid>https://dev.to/ancer/what-happened-when-my-app-finally-met-the-room-2cen</guid>
      <description>&lt;p&gt;Last Friday, the app I have spent most of this year on did the one job it exists for.&lt;/p&gt;

&lt;p&gt;It is called &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 spots each, following a rotating turn that has to stay fair from one year to the next. It does it in a single session, in a room, projected on a wall, in front of the people whose names are in the table.&lt;/p&gt;

&lt;p&gt;Version 1 had that session last year. It took about three hours, and I came home with a notebook full of things to fix. In September I wrote about &lt;a href="https://dev.to/ancer/what-a-year-of-real-use-changed-about-my-app-1634"&gt;what that notebook turned into&lt;/a&gt;, about &lt;a href="https://dev.to/ancer/designing-a-ui-that-gets-projected-in-front-of-a-room-4kdc"&gt;designing for a projected screen&lt;/a&gt; and about &lt;a href="https://dev.to/ancer/testing-when-you-only-get-one-shot-a-year-3605"&gt;testing for one shot a year&lt;/a&gt;. This is the other half: what happened when version 2 met the same room.&lt;/p&gt;

&lt;p&gt;The short version: it worked from start to finish, the session took under an hour, and the notebook has two lines in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same chair, same rule
&lt;/h2&gt;

&lt;p&gt;I did exactly what I did last year. I sat in the room, I kept my hands off the keyboard, and I wrote down every hesitation: every pause before a click, every time the person driving went back on something they had just done, every question from the room that the screen could not answer.&lt;/p&gt;

&lt;p&gt;I don't ask for feedback afterwards. Asking gets you opinions about the app in the abstract. Watching gets you the gap between what someone expected to happen and what happened, and that gap is the only thing worth writing down.&lt;/p&gt;

&lt;p&gt;Last year the notebook ended up with fifteen items. Most of them became version 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under an hour
&lt;/h2&gt;

&lt;p&gt;Last year's session took about three hours. This one took under an hour.&lt;/p&gt;

&lt;p&gt;I am not going to claim all of that for the software. Part of it is a room that had already done this once with an app on the wall, and knew what was coming. But part of it is visible in what actually got used, and the most interesting thing about that list is where it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  What got used
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Verification at eleven.&lt;/strong&gt; Every time an event reached eleven people, the app stopped and opened the verification window: the eleven names, a checkbox for squad leaders, and a way to take someone out before closing. That window is the moment the room checks the app, not the other way around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The search box.&lt;/strong&gt; Last year, the single most repeated moment of the evening was somebody scrolling a table of nearly a hundred rows looking for a name while everyone waited. This year, when someone needed a name, it got typed instead of hunted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An event that didn't get its eleven.&lt;/strong&gt; Not every event fills. In version 1, an event that closed short looked exactly like a full one: five identical green cards. This year the card went amber and said what had happened, and that is what next year's turn will read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A verified event, corrected.&lt;/strong&gt; At some point, an event that had already been verified needed different people in it. Last year, the only tool for that would have erased every event played afterwards, so the correction went onto paper, and from that moment the paper was the record, not the app. This year the event was reopened, corrected and verified again, and the app stayed the record of what happened.&lt;/p&gt;

&lt;p&gt;Three of those four (the search box, the amber card and the reopen) exist because of last year's notebook. None of them was in the rules I was given when I built version 1. That is still the strongest argument I know for shipping something incomplete and then watching it being used.&lt;/p&gt;

&lt;h2&gt;
  
  
  What nobody noticed
&lt;/h2&gt;

&lt;p&gt;Most of what I wrote about in September never came up.&lt;/p&gt;

&lt;p&gt;Cells blocked by unpaid dues or penalties don't say why, so the room sees that a rule was applied without learning anything private about anyone. Dialogs can be dragged out of the way. Notices that need no answer slide into a corner and leave on their own, and only the ones that change the table under everyone's eyes take the whole screen.&lt;/p&gt;

&lt;p&gt;There is not a single line in the notebook about any of it. For that kind of design, that is the result you want. Privacy that works is invisible. A notice that doesn't interrupt is one nobody remembers. If any of those decisions had been wrong, they would have been wrong in public, in front of exactly the people they affect, and they would have been the first thing I wrote down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two lines
&lt;/h2&gt;

&lt;p&gt;This is the whole notebook for this year:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Moving between cells, and marking or unmarking them, from the keyboard instead of double-clicking.&lt;/li&gt;
&lt;li&gt;A scroll that adjusts itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Compare that with last year. Version 1's notes were about things the app could not do: correct a mistake without losing work, tell a full event from an incomplete one, get the group's data in without me. Those were missing capabilities. This year's two lines are about something else entirely: the hands of the person driving.&lt;/p&gt;

&lt;p&gt;That is what a second round looks like, I think. You only notice how many times someone reaches for the mouse once nobody is waiting for you to fix something bigger. Double-clicking works. It is also the slowest way to walk down a list, aim at a cell on a projected table and do it again, with a room watching each move.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes into the next version
&lt;/h2&gt;

&lt;p&gt;Both lines are already built, and they go out with the next version.&lt;/p&gt;

&lt;p&gt;The table is now a single Tab stop. Inside it, the arrow keys move between rows, &lt;em&gt;Home&lt;/em&gt; and &lt;em&gt;End&lt;/em&gt; jump to the first and the last, and the space bar marks or unmarks the person in the event in progress. The part I cared about most is that the keyboard doesn't get its own logic:&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 row the keyboard is on. It goes by position, like the rows' `key`: when an&lt;/span&gt;
&lt;span class="c1"&gt;// event closes and the rota moves people to the back of the queue, focus stays&lt;/span&gt;
&lt;span class="c1"&gt;// where it was on the table instead of chasing the person to the bottom.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filaActiva&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onTeclaFila&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;KeyboardEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;altKey&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;ctrlKey&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;metaKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;hayModalAbierto&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&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;TECLAS_NAVEGACION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&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;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// arrows, Home, End&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="nf"&gt;enfocarFila&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;destinoDe&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Space on the row's edit button belongs to the button.&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;key&lt;/span&gt; &lt;span class="o"&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="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="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;currentTarget&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;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;// Holding it down can't keep marking and unmarking the same person.&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;repeat&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;columnaActual&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="k"&gt;return&lt;/span&gt;
  &lt;span class="c1"&gt;// Exactly what a double-click on the current event's cell does, blocks included.&lt;/span&gt;
  &lt;span class="nf"&gt;onCeldaDblClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;columnaActual&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The space bar calls the same function as the double-click. Every rule that blocks a cell (dues, penalties, the gender of the squad, not repeating an event) applies to both, because there is only one path. A second path would have been a second place for those rules to drift apart, and on this app a rule that drifts doesn't throw an error. It produces a plausible, unfair result.&lt;/p&gt;

&lt;p&gt;Focus going by position is the other detail that only makes sense in this room. Closing an event rotates the queue: everyone who took part moves to the back. If focus followed the person, it would jump to the bottom of the table every time an event closed, and the operator would lose their place in front of everyone.&lt;/p&gt;

&lt;p&gt;The scroll is a single line once the row has focus:&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="nx"&gt;fila&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;preventScroll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// Centred in the visible area: once the row passes the middle of the screen, the&lt;/span&gt;
&lt;span class="c1"&gt;// table scrolls to keep it there. At the very top and bottom there's nothing to&lt;/span&gt;
&lt;span class="c1"&gt;// centre against, and the scroll stops at the edge, which is what you want.&lt;/span&gt;
&lt;span class="nx"&gt;fila&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollIntoView&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;block&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And one guard that only makes sense in front of a room:&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;// With a dialog open, the table ignores the keyboard. The dialog doesn't trap&lt;/span&gt;
&lt;span class="c1"&gt;// focus, so Tab can end up on a row behind it, and the space bar would change&lt;/span&gt;
&lt;span class="c1"&gt;// the selection without anyone seeing it.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hayModalAbierto&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="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;modal-open&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A change nobody can see is the worst kind of change in front of a room. The table simply stops listening while something is covering it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays out
&lt;/h2&gt;

&lt;p&gt;Nothing in the notebook argues for the things I said no to last month. Not a second person editing at the same time, not cloud sync, not a configurable number of events and spots, not a general undo. One person drove, in one room, and the outcome counted because everyone watched it happen. None of those features would have made that hour better, and most of them would have made it different.&lt;/p&gt;

&lt;p&gt;The temptation after a good night is to read it as permission to add things. I am reading it the other way: the notebook is the spec, and the spec got shorter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only metric
&lt;/h2&gt;

&lt;p&gt;Software that runs once a year has no analytics worth the name. No retention curve, no error tracking, no release every week. The night is the whole feedback loop, and whatever you learn in that room is all the feedback you get until the next one.&lt;/p&gt;

&lt;p&gt;So the only measure I really trust is the length of the notebook. Last year: fifteen items, pages of them. This year: two lines. Next year I will sit in the same chair, with the same notebook, and keep my hands off the same keyboard. A blank page is the goal. I don't expect to get it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The full case study, with screenshots made from test data, is on my site: &lt;a href="https://ancer.es/en/project/rueda-ligeros" rel="noopener noreferrer"&gt;https://ancer.es/en/project/rueda-ligeros&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>ux</category>
      <category>buildinpublic</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Testing When You Only Get One Shot a Year</title>
      <dc:creator>Christian • ancer</dc:creator>
      <pubDate>Wed, 30 Sep 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ancer/testing-when-you-only-get-one-shot-a-year-3605</link>
      <guid>https://dev.to/ancer/testing-when-you-only-get-one-shot-a-year-3605</guid>
      <description>&lt;p&gt;Most software gets a second chance. A bad deploy on Tuesday is fixed by Wednesday morning, and by Friday nobody remembers it happened. That safety net is so ordinary that it disappears from the way we talk about quality: we argue about coverage percentages because, underneath, we assume anything we miss can be corrected later.&lt;/p&gt;

&lt;p&gt;I spent the last months on an app where that assumption is false, and it changed how I decide what to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deployment model is the requirement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rueda de Actos&lt;/strong&gt; is a Windows desktop app I built for a &lt;em&gt;filà&lt;/em&gt;, one of the associations that take part in the Moros y Cristianos festivities in Alcoy, Spain. Once a year the board sits down and hands out the spots for five acts, eleven places each, following a rotation that decides who takes part and who waits another year.&lt;/p&gt;

&lt;p&gt;That session is the entire product. It happens once. It happens live, with the screen projected in front of the room. And it happens on a machine that is not connected to anything.&lt;/p&gt;

&lt;p&gt;Three properties fall out of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No network.&lt;/strong&gt; The app never opens an outbound connection. Everything lives in a local SQLite file inside the user's Windows profile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No auto-update.&lt;/strong&gt; There is no update channel to check, because there is no network. A new version means someone runs a new installer over the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One live run per year.&lt;/strong&gt; Between two real sessions there are twelve months, and I am not in the room for eleven and a half of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together they remove the thing every web developer leans on without noticing: I cannot ship a fix before it matters. The build installed on that machine is the build that runs. By the time anyone notices a problem, the decision it affected has already been made, out loud, in front of the people it affects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage percentage is the wrong question
&lt;/h2&gt;

&lt;p&gt;The question I actually needed to answer was not &lt;em&gt;how much of this is covered&lt;/em&gt;. It was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If this path is wrong, when do we find out, and what does it cost to fix it then?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That splits the codebase into three groups.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wrong and obvious immediately.&lt;/strong&gt; The user sees it, retries, moves on. Cheap. A test here buys very little.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong and obvious later, with the data still intact.&lt;/strong&gt; Annoying, recoverable, worth some tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong and &lt;em&gt;plausible&lt;/em&gt;.&lt;/strong&gt; No error, no crash, nothing red anywhere. The app produces a result that looks exactly like a correct result and isn't. Or data quietly stops existing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Roughly 400 tests, and almost all of them live in the third group.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tests actually are
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The rotation
&lt;/h3&gt;

&lt;p&gt;This is the critical one, and it is critical precisely because it never fails loudly.&lt;/p&gt;

&lt;p&gt;If the wheel logic is wrong, nothing throws. The screen shows eleven names per act, correctly formatted, in a table that looks right. What has actually happened is that somebody was skipped, or got a second turn before someone else got a first. Nobody sees a stack trace. Someone notices months later that they have been left out two years running, and at that point the damage is social, not technical. In a volunteer association, fairness is the entire reason this software exists.&lt;/p&gt;

&lt;p&gt;So the rotation is not tested as a function with a known return value. It is tested as a set of invariants over simulated years:&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;// simplified&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never grants a second turn before everyone has had a first&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="o"&gt;=&amp;gt;&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;wheel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;newWheel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;makeMembers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&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;let&lt;/span&gt; &lt;span class="nx"&gt;year&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="nx"&gt;year&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;year&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;assigned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runYear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wheel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// 5 acts, 11 places each&lt;/span&gt;
    &lt;span class="nx"&gt;wheel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wheel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;assigned&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;maxTurns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wheel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;minTurns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wheel&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThanOrEqual&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The valuable tests here run the wheel across several years and then assert a &lt;em&gt;property&lt;/em&gt; of the outcome, never an exact list of names. Exact-list assertions break the moment a rule changes, and they teach you nothing when they do. Invariants survive rule changes, which is the whole point: they encode what fairness means, not what the current implementation happens to produce.&lt;/p&gt;

&lt;p&gt;The same style covers the awkward cases that real use produced. Skipping an act must not advance anyone's turn. Closing an act half full must not pretend it was complete. Reopening a closed act to correct a mistake must leave the queue exactly as it would have been had the correction been made the first time.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The rules that block a spot
&lt;/h3&gt;

&lt;p&gt;Gender rules for squads, payment status, sanctions, licence validity, and the rule against repeating an act you already did in a previous year. On top of that, three configuration switches that each turn one of those rules off, because different associations work differently.&lt;/p&gt;

&lt;p&gt;They interact, so they are tested as a matrix rather than one by one. A member can be eligible under the gender rule and blocked by the licence, or blocked by both, or unblocked by a switch that only lifts one of the two.&lt;/p&gt;

&lt;p&gt;One of these tests is not about logic at all. Payment and sanction status block a spot but must never be rendered in the projected table, because the room is watching. So there is a test asserting that the sensitive field does not appear in the table markup for a blocked member. It is a privacy requirement, and writing it as a test is what stops it being quietly undone by a future refactor of that component.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Schema migrations
&lt;/h3&gt;

&lt;p&gt;The tests nobody applauds, and the ones I would keep if I could keep only one group.&lt;/p&gt;

&lt;p&gt;Every year, the installed version is a year old. Upgrading means running the new installer over the previous one, and the database sitting in the user's profile is the real one, with the real history of who took part in what. There is no seed data to fall back on and no server-side copy anywhere.&lt;/p&gt;

&lt;p&gt;So every schema version ships with a migration, and every migration ships with a test that starts from a fixture database at the &lt;em&gt;previous&lt;/em&gt; version, migrates it, and asserts everything survived: members, years, acts, flags, corrections. Not "the migration completed without throwing", but the row counts and the row contents, plus whatever the new version adds.&lt;/p&gt;

&lt;p&gt;Two more tests guard the edges. Migrations must be ordered and idempotent, and the app must refuse to open a database written by a newer version rather than trying its best. That last one sounds theoretical until you remember how these installers travel. Reinstalling last year's build from an old pendrive is a completely realistic accident.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Import and export, in both directions
&lt;/h3&gt;

&lt;p&gt;The app starts empty. Real data gets in through an import from the spreadsheet the association already had, which is to say: a file a human maintained by hand, for years, with no schema in mind.&lt;/p&gt;

&lt;p&gt;That means accents, trailing spaces, inconsistent capitalisation, blank rows in the middle, a header row that is not the first row, and a name column that might be called &lt;code&gt;Nombre&lt;/code&gt;, &lt;code&gt;NOMBRE&lt;/code&gt; or &lt;code&gt;Nombre y apellidos&lt;/code&gt;. The alternative to importing that file is typing everyone in by hand, which kills a tool before it is ever used. The messy path is the main path.&lt;/p&gt;

&lt;p&gt;Excel and JSON both work in both directions, and the tests treat that as a round trip: export the full wheel, re-import it into an empty database, assert the resulting state is identical. An export you cannot re-import is not a backup, it is a report.&lt;/p&gt;

&lt;p&gt;The fixtures are deliberately dirty. The clean file passes on day one and never catches anything again.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The build itself
&lt;/h3&gt;

&lt;p&gt;The suite runs on every build of the installer. The installer is not produced by hand from whatever happens to be in my working tree: an automated process builds it from source with the same pinned dependency versions the tests ran against. If the suite fails, no installer comes out.&lt;/p&gt;

&lt;p&gt;That matters more here than in a service I can redeploy. The artefact is physically handed over. The file that reaches that machine is the last point at which anything can still be corrected, so it should not be possible for it to exist in an untested state.&lt;/p&gt;

&lt;p&gt;It is not perfect. The installer is still unsigned, so Windows SmartScreen warns the first time it runs, and no amount of testing fixes that particular rough edge. It is a signing certificate, not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately don't test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Visual layout.&lt;/strong&gt; Checked by using the app, not by asserting on pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Electron and SQLite themselves.&lt;/strong&gt; They have their own test suites, written by people who know them better than I do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Components with no logic in them.&lt;/strong&gt; A unit test there mostly proves I typed the props twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance.&lt;/strong&gt; Dozens of people and a handful of years of history. Nothing here will ever be slow, and a benchmark would only give me something to maintain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are unrecoverable if they are wrong. That was the only criterion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I ended up with
&lt;/h2&gt;

&lt;p&gt;Test budget is finite, whatever anyone says about coverage. The way I spend it now is by asking, for every path, when a mistake would surface and what fixing it would cost at that point.&lt;/p&gt;

&lt;p&gt;Loud failures are cheap, because the user becomes the test. Silent, plausible failures are expensive, because nothing tells you they happened, and by the time something does, the moment has passed. The rotation is the purest example I have worked on: no exception, no error state, a result that looks completely reasonable, and a consequence that arrives months later and lands on a person rather than on a log.&lt;/p&gt;

&lt;p&gt;In a few weeks the app runs for real again, offline, on one machine, in front of a room. Feature work has already stopped. From here it is tests, polish and leaving things alone.&lt;/p&gt;

&lt;p&gt;I will write about how it goes.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>electron</category>
      <category>softwareengineering</category>
      <category>programming</category>
    </item>
    <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>
  </channel>
</rss>
