<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>From ClickUp to Vaadin on Sven Ruppert</title><link>https://svenruppert.com/series/from-clickup-to-vaadin/</link><description>Sven Ruppert — Java Veteran, Speaker, Trainer &amp; Bushcrafter. Articles, talks, workshops and videos on Core Java, Cybersecurity, Vaadin and Developer Relations.</description><generator>Hugo</generator><language>en</language><managingEditor>sven.ruppert@gmail.com (Sven Ruppert)</managingEditor><webMaster>sven.ruppert@gmail.com (Sven Ruppert)</webMaster><copyright>© 2026 Sven Ruppert</copyright><atom:link href="https://svenruppert.com/series/from-clickup-to-vaadin/index.xml" rel="self" type="application/rss+xml"/><image><url>https://svenruppert.com/img/sven-ruppert.jpg</url><title>Sven Ruppert</title><link>https://svenruppert.com/series/from-clickup-to-vaadin/</link></image><lastBuildDate>Fri, 21 Aug 2026 10:00:00 +0200</lastBuildDate><item><title>From ClickUp to Vaadin — Part 2: The Build and the Balance</title><link>https://svenruppert.com/posts/from-clickup-to-vaadin-part-2-the-build-and-the-balance/</link><pubDate>Fri, 21 Aug 2026 10:00:00 +0200</pubDate><author>sven.ruppert@gmail.com (Sven Ruppert)</author><dc:creator>Sven Ruppert</dc:creator><guid isPermaLink="true">https://svenruppert.com/posts/from-clickup-to-vaadin-part-2-the-build-and-the-balance/</guid><description>The second part: from the seven-view design of Part 1 to a running application in seventy-two commits, and an honest reckoning of the calculation that has tipped over. What the tool did well (drag-and-drop as ordinary Java, twelve lines from a Select to the hardened domain method, thirty-five stylesheet lines for a look of one's own) and what it did not (an import silently truncated at one hundred entries). Browserless view tests that reach into the real component tree, mutation testing as a check on tests written by the same assistant that wrote the code, and a balance sheet that names both columns of what a rented service actually costs — including the ability to leave.</description><content:encoded>&lt;![CDATA[<h2 id="where-we-stand">Where we stand</h2><p>The first part ended with a model and a reservation. Anyone who has read it knows both; for anyone joining here, let the starting position be given in a few sentences, for without it much of what follows remains mere assembly instructions.</p><p>The point of departure was a flat list of tasks in a foreign tool whose actual structure did not reside in the system but in a naming convention. From it came a four-level ordering of content, and from a single status field, overloaded over the years with sixteen values, came three mutually independent dimensions: acquisition, editorial work and production. The history of each of these dimensions is not overwritten but appended, ordered by an ascending sequence number rather than by a timestamp. On this foundation there arose a domain concept, a catalogue of processes, and out of these seven views, whose design finally lay before me as an interactive model.</p><p>The reservation ran that a model which has to hold its own only against itself always wins. This second part is the redemption: the design becomes an application through which the real holdings pass.</p><p>Let the technical ground be named in advance, so that no guessing is required later on. The application is written throughout in Java, the interface with Vaadin; storage is handled by EclipseStore over an object graph, delivery by an embedded Jetty, and permissions by a slender tool of my own. Neither an application server nor a comprehensive framework is employed; where the JDK suffices, it suffices. No introduction to Vaadin&rsquo;s building blocks is offered — they are presumed familiar, and what is interesting is in any case not which component displays something, but at which points the building blocks did not suffice.</p><p>What follows is therefore not a success story but a workshop report: the build in the order of the design briefs, the transfer of the model into the interface, the two views without a ready-made component, the bridge from the old system, verification without a browser, and at the end a reckoning that also names what went wrong.</p><h2 id="the-build-in-the-order-of-the-briefs">The build, in the order of the briefs</h2><p>Between the design and the running application lies that stretch about which articles of this kind usually keep silent — presumably because it is unspectacular. Yet it is precisely this stretch that decides whether the calculation claimed at the outset works out. The implementation followed directly on from the view briefs and comprised seventy-two commits in the end. The path there ran through three discernible phases: the building, the alignment, the hardening.</p><p>The building followed an order that emerged inevitably from the preceding stations. First the domain core with the status dimensions and the append-only history, then the aggregates together with the language rule as an invariant, after that the persistence, the permissions and the navigation scaffold. Only then came the views — individually, along the view briefs: the topic workspace, the language-version editor, the publication view, the editorial board, the history view, the publication places and finally the import. This order is no coincidence; it is the briefs worked through from top to bottom — with a single deviation: the publication view, the dramaturgical core, was pulled ahead of the editorial board. Anyone wondering what the detour via process catalogue and view briefs was good for will find the answer here: the implementation no longer had to invent anything.</p><p>One detail of this phase amuses me in retrospect. The domain core was first written with German identifiers —<code>Statusverlauf</code>,<code>Statuswechsel</code>,<code>Sprachfassung</code> — exactly as we had spoken about them in conversation. A refactoring of its own later tidied this up and translated the entire code into English, a good seventeen hundred changed lines across fifty-seven files. What appeared in the first part as<code>StatusHistory</code> had borne a different name before. The domain language of the conversation is simply not automatically the language of the code, and it is better to decide that early than late.</p><h2 id="the-model-in-the-interface">The model in the interface</h2><p>So far I have spoken about the interface without showing it. That is unsatisfactory, for the interesting question is precisely how such a model reappears in an interface — and whether anything is lost along the way. Let us begin with the screen that carries the core of the whole.</p><p>The two-column publication view illustrated in the first part arises from a handful of lines:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="n">FlexLayout</span><span class="w"/><span class="n">columns</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">FlexLayout</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">columns</span><span class="p">.</span><span class="na">setWidthFull</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">columns</span><span class="p">.</span><span class="na">getStyle</span><span class="p">().</span><span class="na">set</span><span class="p">(</span><span class="s">"gap"</span><span class="p">,</span><span class="w"/><span class="s">"var(--lumo-space-l)"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">columns</span><span class="p">.</span><span class="na">setFlexWrap</span><span class="p">(</span><span class="n">FlexLayout</span><span class="p">.</span><span class="na">FlexWrap</span><span class="p">.</span><span class="na">WRAP</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">columns</span><span class="p">.</span><span class="na">add</span><span class="p">(</span><span class="n">acquisitionColumn</span><span class="p">(),</span><span class="w"/><span class="n">productionColumn</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">body</span><span class="p">.</span><span class="na">add</span><span class="p">(</span><span class="n">columns</span><span class="p">);</span></span></span></code></pre></div></div><p>More revealing than the layout, however, is what happens inside such a column:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="n">Div</span><span class="w"/><span class="nf">productionColumn</span><span class="p">()</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">Select</span><span class="o">&lt;</span><span class="n">ProductionStatus</span><span class="o">&gt;</span><span class="w"/><span class="n">select</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">Select</span><span class="o">&lt;&gt;</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">select</span><span class="p">.</span><span class="na">setItems</span><span class="p">(</span><span class="n">ProductionStatus</span><span class="p">.</span><span class="na">values</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">select</span><span class="p">.</span><span class="na">setValue</span><span class="p">(</span><span class="n">publication</span><span class="p">.</span><span class="na">productionStatus</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">select</span><span class="p">.</span><span class="na">addValueChangeListener</span><span class="p">(</span><span class="n">e</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">e</span><span class="p">.</span><span class="na">getValue</span><span class="p">()</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="n">e</span><span class="p">.</span><span class="na">getValue</span><span class="p">()</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="n">publication</span><span class="p">.</span><span class="na">productionStatus</span><span class="p">())</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">publication</span><span class="p">.</span><span class="na">changeProductionStatus</span><span class="p">(</span><span class="n">e</span><span class="p">.</span><span class="na">getValue</span><span class="p">(),</span><span class="w"/><span class="n">actor</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">repo</span><span class="p">.</span><span class="na">persist</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">render</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">});</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">...</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>These twelve lines are the actual argument. The selection field is populated directly with the values of the enumeration — no transfer objects, no strings, no second maintenance of the states between interface and model. The event handler calls the hardened domain method directly; between the click and the appending to the status history there lies no network call, no serialisation, no second language. The type parameter moreover ensures that the compiler complains if a dimension were to receive a value that does not belong to it. What began in the first part as disentangling arrives here.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch03-bild-01.png" alt="Publication overview of the running application — one row per publication, acquisition and production side by side" loading="lazy" decoding="async"/><p><em>Figure 1: The publication overview of the running application — one row per publication, with acquisition and production state side by side.</em></p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch03-bild-02.png" alt="Publication view of the running application — acquisition and production as two equal columns" loading="lazy" decoding="async"/><p><em>Figure 2: The publication view of the running application — acquisition on the left, production on the right, each with its own state and history.</em></p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch03-bild-03.png" alt="History workspace of the running application — status changes of all three dimensions in one list" loading="lazy" decoding="async"/><p><em>Figure 3: The history overview of the running application — status changes of all three dimensions in one list, newest first, each row with a jump into its complete chain.</em></p><h2 id="the-editorial-board-across-four-steps">The editorial board across four steps</h2><p>How such a view improves over time can be read particularly well from the editorial board. It became what it is today in four steps, and each step had a nameable trigger. The improvement requests were numbered just as the processes had been before, so that every commit message carries its occasion with it — a small amount of orderliness that pays off when retracing the path.</p><p><strong>Step one — the requirement from the view brief.</strong> What was called for was a board showing the editorial progress of all parts at a glance, with one column per work state. Exactly that came about. Moving a card, however, was done through a selection field sitting on every single card:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="n">Select</span><span class="o">&lt;</span><span class="n">EditorialState</span><span class="o">&gt;</span><span class="w"/><span class="n">move</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">Select</span><span class="o">&lt;&gt;</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">move</span><span class="p">.</span><span class="na">setItems</span><span class="p">(</span><span class="n">EditorialState</span><span class="p">.</span><span class="na">values</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">move</span><span class="p">.</span><span class="na">setValue</span><span class="p">(</span><span class="n">part</span><span class="p">.</span><span class="na">editorialState</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">move</span><span class="p">.</span><span class="na">setWidthFull</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">move</span><span class="p">.</span><span class="na">addValueChangeListener</span><span class="p">(</span><span class="n">e</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="p">{</span><span class="w"/><span class="p">...</span><span class="w"/><span class="p">});</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">card</span><span class="p">.</span><span class="na">add</span><span class="p">(</span><span class="n">title</span><span class="p">,</span><span class="w"/><span class="n">info</span><span class="p">,</span><span class="w"/><span class="n">move</span><span class="p">);</span></span></span></code></pre></div></div><p>This satisfied the requirement, but it was not a board — it was a list with drop-down menus. The view brief had in fact provided for drag-and-drop, yet the first implementation took the shorter road — a behaviour I have observed in the assistant more than once: it fulfils the letter of the requirement, not its spirit, as long as one does not follow up.</p><p><strong>Step two — the request for genuine drag-and-drop.</strong> The follow-up was raised as request F4, and here it becomes apparent what is meant by consistently doing everything in Java:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="n">DropTarget</span><span class="o">&lt;</span><span class="n">Div</span><span class="o">&gt;</span><span class="w"/><span class="n">dropTarget</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">DropTarget</span><span class="p">.</span><span class="na">create</span><span class="p">(</span><span class="n">col</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">dropTarget</span><span class="p">.</span><span class="na">addDropListener</span><span class="p">(</span><span class="n">e</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">onDrop</span><span class="p">(</span><span class="n">state</span><span class="p">));</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">DragSource</span><span class="o">&lt;</span><span class="n">Div</span><span class="o">&gt;</span><span class="w"/><span class="n">dragSource</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">DragSource</span><span class="p">.</span><span class="na">create</span><span class="p">(</span><span class="n">card</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">dragSource</span><span class="p">.</span><span class="na">setDragData</span><span class="p">(</span><span class="n">part</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">dragSource</span><span class="p">.</span><span class="na">addDragStartListener</span><span class="p">(</span><span class="n">e</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">draggedPart</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">part</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="n">dragSource</span><span class="p">.</span><span class="na">addDragEndListener</span><span class="p">(</span><span class="n">e</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">draggedPart</span><span class="w"/><span class="o">=</span><span class="w"/><span class="kc">null</span><span class="p">);</span></span></span></code></pre></div></div><p>No JavaScript, no event handling in the browser, no mediation between two worlds — drag-and-drop is an ordinary Java interface here. And both paths of interaction converge on the same domain method, which prevents two routes from drifting apart:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="kt">void</span><span class="w"/><span class="nf">onDrop</span><span class="p">(</span><span class="n">EditorialState</span><span class="w"/><span class="n">target</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">draggedPart</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">&amp;&amp;</span><span class="w"/><span class="n">draggedPart</span><span class="p">.</span><span class="na">editorialState</span><span class="p">()</span><span class="w"/><span class="o">!=</span><span class="w"/><span class="n">target</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">draggedPart</span><span class="p">.</span><span class="na">changeState</span><span class="p">(</span><span class="n">target</span><span class="p">,</span><span class="w"/><span class="n">actor</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">repo</span><span class="p">.</span><span class="na">persist</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">draggedPart</span><span class="w"/><span class="o">=</span><span class="w"/><span class="kc">null</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">refresh</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>It is worth noting that the selection field was deliberately retained at first; the source code expressly recorded it as an equally capable, accessible fallback for anyone unable or unwilling to drag with a mouse.</p><p><strong>Step three — the request for a second form of display.</strong> In daily use it turned out that the board becomes unwieldy with many parts and that a tabular view of the same data was missing. With this addition the selection field on the cards became dispensable, since the table offered a mouse-free route in any case. The third step therefore did not merely add something, but took something away: the selection field disappeared together with its import, and the card has since carried only title, part and markers. It belongs to the honesty of this account that such a removal comes more easily when an assistant carries the change through without appreciable effort; by hand I would probably have left the working selection field where it was.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch04-bild-01.png" alt="Editorial board of the running application — columns per editorial state, cards without the dispensable select" loading="lazy" decoding="async"/><p><em>Figure 1: The editorial board of the running application — one column per work state, the cards without the selection field that had become dispensable.</em></p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch04-bild-02.png" alt="The same data as a table — grouped by editorial state" loading="lazy" decoding="async"/><p><em>Figure 2: The same data as a table — grouped by work state.</em></p><p><strong>Step four — the objection of an analysis tool.</strong> The last step did not come out of conversation. The board kept the filtered parts cached in a field in order to share them between the column and table views — convenient, but a piece of state that can go stale and which the static analysis rightly complained about. In its place came a method that recomputes on demand:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">private</span><span class="w"/><span class="n">List</span><span class="o">&lt;</span><span class="n">Part</span><span class="o">&gt;</span><span class="w"/><span class="nf">filteredParts</span><span class="p">()</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">PublicationsFilter</span><span class="w"/><span class="n">filter</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">PublicationsFilter</span><span class="p">.</span><span class="na">current</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="w"/><span class="n">repo</span><span class="p">.</span><span class="na">issues</span><span class="p">().</span><span class="na">stream</span><span class="p">()</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">.</span><span class="na">filter</span><span class="p">(</span><span class="n">filter</span><span class="p">::</span><span class="n">matchesTitle</span><span class="p">)</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">.</span><span class="na">flatMap</span><span class="p">(</span><span class="n">i</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">i</span><span class="p">.</span><span class="na">parts</span><span class="p">().</span><span class="na">stream</span><span class="p">())</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">.</span><span class="na">toList</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>Four steps: a list with drop-down menus became a board, the board became a leaner board with two forms of display, and in the end a cache disappeared that could have attracted defects. Further requests followed later — compound search filters and an optional AND or OR combination of tags — yet the pattern remained the same. None of these steps is remarkable in itself. What is remarkable is that they took place at this density, for the fact that an improvement barely weighs anything at all decides whether one makes it.</p><p>Finally, a small matter that pleases me, because it carries the method from the first part right into the source code. Every view names in its description the processes it serves:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="cm">/**</span></span></span><span class="line"><span class="cl"><span class="cm"> * Served processes: P0008–P0013.</span></span></span><span class="line"><span class="cl"><span class="cm"> */</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="nd">@Route</span><span class="p">(</span><span class="n">value</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">PublicationView</span><span class="p">.</span><span class="na">NAV</span><span class="p">,</span><span class="w"/><span class="n">layout</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">MainLayout</span><span class="p">.</span><span class="na">class</span><span class="p">)</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="nd">@VisibleFor</span><span class="p">(</span><span class="n">AuthorizationRole</span><span class="p">.</span><span class="na">USER</span><span class="p">)</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="kd">public</span><span class="w"/><span class="kd">class</span><span class="nc">PublicationView</span><span class="w"/><span class="kd">extends</span><span class="w"/><span class="n">Composite</span><span class="o">&lt;</span><span class="n">VerticalLayout</span><span class="o">&gt;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">implements</span><span class="w"/><span class="n">HasUrlParameter</span><span class="o">&lt;</span><span class="n">String</span><span class="o">&gt;</span><span class="p">,</span><span class="w"/><span class="n">I18nSupport</span><span class="w"/><span class="p">{</span></span></span></code></pre></div></div><p>Anyone wanting to know in six months&rsquo; time why this screen exists will find the answer where they would look anyway.</p><h2 id="the-seam-a-look-of-ones-own-in-thirty-five-lines">The seam: a look of one&rsquo;s own in thirty-five lines</h2><p>Now to that seam which I named openly at the end of the first part: the draft was a free front-end mock-up, and Vaadin brings its own visual language. How does one close that? The answer turned out to be smaller than I had expected. A single commit brought the IBM Plex fonts and the colour palette into the application, and it comprised thirty-five changed lines in a single stylesheet. This is possible because Vaadin&rsquo;s Lumo styling can be redefined through CSS custom properties — one exchanges typeface, colours and corner radii without touching a single component. The seam was real, then, but it was narrow. Whoever wants a look of their own can have it; they need only know that it comes about in the stylesheet and not in the Java code.</p><h2 id="the-bridge-the-import-as-an-etl">The bridge: the import as an ETL</h2><p>Between the old system and the new one stands a bridge, and it is of a different kind from everything built so far. It does not belong to the application but to the transition. It is needed for as long as data resides in ClickUp, and it disappears the moment that ceases to be the case. A throwaway piece, then — yet one on which the entire migration depends, because not a single correspondence between source and target arises of its own accord. That is precisely why the import is built as an ETL rather than as a script that runs once and hopes.</p><p>The value of the tripartite structure lies in the strict separation of the three acts. Extraction fetches the task list over the network, writes it unaltered to disk, and is thereby finished. Transformation and loading then work exclusively against that local copy and may be repeated at will. This is no formality but the heart of the matter: the interesting work resides in the transformation, and transformations are rarely got right at the first attempt. Anyone who reaches across the network for every attempt turns a swift loop into a laborious affair and, into the bargain, surrenders to the rate limits of a foreign interface.</p><p>The raw copy consists of two files, the JSON document and a note recording the moment of extraction. What is remarkable is what gets preserved: every field of the source, not merely those the transformation currently consumes. That restraint is deliberate. What one does not need today cannot be retrieved later, once the source has been switched off. The store itself remains deliberately undemanding — it is handed its directory, and the timestamp along with it, rather than consulting a clock, in which it follows the domain model&rsquo;s abstention from embedded time. On the network side, the JDK&rsquo;s HTTP client suffices; no framework is required at any point.</p><p>The actual act of transformation is the one towards which the first part of this account was working: the distribution of the conflated status field. The analysis has become a mapping table, and the mapping table an executable case distinction. And here a limitation must be named openly, one the migration cannot resolve. Only the editorial dimension is populated. Production and acquisition were never recorded independently in ClickUp, and no transformation invents what was never gathered. The parts taken over therefore begin in the neutral initial states of the two other dimensions. The disentanglement acts forwards, not retrospectively — it provides the model in which the distinction can be maintained from now on.</p><p>Two decisions at this juncture strike me as the most instructive in the whole module. First, an unrecognised status value does not drop out but drops into the backlog. A record that cannot be mapped is not lost; it lands visibly where somebody will have to take it up again. Second, the report of a run carries not merely tallies but a frequency-counted mapping of the form &ldquo;source value became target state&rdquo;. This turns the import into an instrument of inspection: one reads off which of the sixteen ClickUp values fell how often onto which editorial state, and holds the mapping table devised at the desk against the actual data.</p><p>That leaves repeatability, and it rests on a single field. Every case taken over records its origin, the identifier of the ClickUp task. Should loading encounter an origin already recorded, it passes the record over. A second run therefore creates nothing but merely reports what it skipped. Only this property makes the bridge serviceable in daily use: one imports, examines the distribution, improves the mapping, imports again — and need tidy nothing in between.</p><p>The self-description written into the module puts it exactly: a throwaway module in production, a workhorse in development. The reckoning becomes wholly honest only later, however. Extraction walks the pages of the foreign interface in a loop, and the fact that it did not do so at first was the most consequential error of the entire undertaking. That it was noticed at all is owed to the raw copy: because the extract lies complete on disk, one can count what arrived and compare it with what ought to have been there.</p><h2 id="verification-without-a-browser">Verification without a browser</h2><p>Every station of this account so far runs towards the same uncomfortable question, and I have only brushed against it until now. If an assistant writes the code and then writes the tests for it, what do those tests actually prove? They check what the assistant understood. A misunderstanding propagates from the code into the test, where it is not exposed but confirmed. Whoever works this way and trusts green bars has built themselves a machine that agrees with them.</p><p>First to the craft side, for without it there would be nothing to verify at all. A Vaadin interface can be tested without a browser: the view is created in the same runtime in which the check runs, and one reaches into the real component tree rather than into rendered HTML. No browser, no remote-control protocol, no waiting seconds — and above all no brittleness with respect to labels and positions. Such a check looks like this:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Test</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="nd">@DisplayName</span><span class="p">(</span><span class="s">"renders both lifecycle columns with their initial states"</span><span class="p">)</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="kt">void</span><span class="w"/><span class="nf">rendersTwoColumns</span><span class="p">()</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">UI</span><span class="p">.</span><span class="na">getCurrent</span><span class="p">().</span><span class="na">navigate</span><span class="p">(</span><span class="n">PublicationView</span><span class="p">.</span><span class="na">class</span><span class="p">,</span><span class="w"/><span class="n">vId</span><span class="p">.</span><span class="na">toString</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertEquals</span><span class="p">(</span><span class="s">"Publication"</span><span class="p">,</span><span class="w"/><span class="n">$view</span><span class="p">(</span><span class="n">H1</span><span class="p">.</span><span class="na">class</span><span class="p">).</span><span class="na">first</span><span class="p">().</span><span class="na">getText</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">List</span><span class="o">&lt;</span><span class="n">String</span><span class="o">&gt;</span><span class="w"/><span class="n">spanTexts</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">$view</span><span class="p">(</span><span class="n">Span</span><span class="p">.</span><span class="na">class</span><span class="p">).</span><span class="na">all</span><span class="p">().</span><span class="na">stream</span><span class="p">()</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">.</span><span class="na">map</span><span class="p">(</span><span class="n">Span</span><span class="p">::</span><span class="n">getText</span><span class="p">).</span><span class="na">collect</span><span class="p">(</span><span class="n">Collectors</span><span class="p">.</span><span class="na">toList</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertTrue</span><span class="p">(</span><span class="n">spanTexts</span><span class="p">.</span><span class="na">contains</span><span class="p">(</span><span class="s">"ACQUISITION"</span><span class="p">),</span><span class="w"/><span class="s">"left column is Acquisition"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertTrue</span><span class="p">(</span><span class="n">spanTexts</span><span class="p">.</span><span class="na">contains</span><span class="p">(</span><span class="s">"PRODUCTION"</span><span class="p">),</span><span class="w"/><span class="s">"right column is Production"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertTrue</span><span class="p">(</span><span class="n">spanTexts</span><span class="p">.</span><span class="na">contains</span><span class="p">(</span><span class="s">"REQUESTED"</span><span class="p">),</span><span class="w"/><span class="s">"acquisition starts at REQUESTED"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertTrue</span><span class="p">(</span><span class="n">spanTexts</span><span class="p">.</span><span class="na">contains</span><span class="p">(</span><span class="s">"PLANNED"</span><span class="p">),</span><span class="w"/><span class="s">"production starts at PLANNED"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">assertEquals</span><span class="p">(</span><span class="n">2</span><span class="p">,</span><span class="w"/><span class="n">$view</span><span class="p">(</span><span class="n">Select</span><span class="p">.</span><span class="na">class</span><span class="p">).</span><span class="na">all</span><span class="p">().</span><span class="na">size</span><span class="p">(),</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="s">"one advance-Select per dimension"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>What is remarkable here is less the technique than what is actually being checked. The claim that carries this entire article — two independent lifecycles on a single object — has at this point become a guarantee that is recomputed with every build. Were someone to remove one of the two columns, the test would fail. The disentangling from the analysis is thus not merely modelled and drawn, but pinned down.</p><p>Equally revealing is the preparation of such checks. They build their own body of data from scratch — a publication place, a topic, a part, a language version, a planned publication — and store it in a second, purely in-memory persistence implementation that stands alongside the file-based one. That an object graph can be replaced by a stand-in so easily is a quiet advantage of doing without a database: there is no schema to create beforehand, and no server to start.</p><p>By now the undertaking comprises sixty-one test classes with close to three hundred checks; twenty-one of those classes are browserless view tests. And here the opening question returns: these figures impress, but on their own they prove nothing. The truncated import from the previous station was surrounded by tests that all ran green.</p><p>This is why one needs means of verification that come from outside — that do not know what the assistant meant. The first is static analysis, which found seventeen complaints in the code of the publication management: missing serialisation identifiers, mutable collections handed straight out. None of it was an error of thought, all of it was craftsmanlike negligence, and not one of the tests would ever have noticed.</p><p>The second and more effective means is mutation testing, and it answers precisely the question at issue here. It deliberately alters the code in many places in small ways — inverts a condition, swaps a return value, removes a call — and then runs the tests. If no test notices the alteration, one has a test that passes but secures nothing. Where the usual coverage measurement only counts which lines were executed, mutation testing asks whether their behaviour is specified at all. For tests that come from the same instance as the code, that is the only honest counter-check.</p><p>How this is arranged in the project is, to my mind, the actual lesson of this chapter. A single global threshold would be useless, because it either overtaxes the interface or undertaxes the core. Instead, a dedicated file keeps per-package floors, and its comment states the expectations openly: the load-bearing security and domain packages lie between seventy-five and ninety-five per cent, the view packages deliberately far below. The floors are deliberately set two or three points below the measured state, so that ordinary refactorings do not trip them while a genuine regression does; they may be raised only after a sustained better state, and lowered only with a written reason.</p><p>Such a reason does in fact stand in the file, and it is more instructive than any success report: the overall floor was lowered when new interface components were added that generate many mutants in pure assembly code — code, that is, which plugs components together and in which there is little to assert. That is the honest way to handle a metric: not to flatter it, but to write down why it moved.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-2-ch07-bild-01.svg" alt="Levels of verification and what each of them catches" loading="lazy" decoding="async"/><p><em>Figure 1: Levels of verification and what each of them catches — inner consistency can be automated, correctness cannot.</em></p><p>What remains is the part no tool covers. Neither the static analysis nor the mutation testing would have noticed that the import breaks off at one hundred tasks, for both check the agreement of code and test, not the agreement of result and reality. That falls to the human who knows their own body of data. The division of labour that follows from this is unspectacular but sound: the assistant produces, the tools verify inner consistency, and the human verifies whether the whole thing is the right thing at all.</p><h2 id="bug-fixing-and-fitness-for-daily-use">Bug fixing and fitness for daily use</h2><p>The bug fixing produced the most instructive find of the whole undertaking. The import ran flawlessly at first — too flawlessly. It fetched one hundred tasks and reported success. That the ClickUp interface delivers its results page by page and silently truncates after a hundred entries only came to light when the figures did not match the actual body of data. The fix walked the pages in a loop and concatenated them into a single raw document, with a hard upper bound so that a misbehaving interface can never lead into an endless loop. This is the sort of error no tool finds for you: there was no exception, no failing test, no warning — only a result that looked plausible and was wrong. Here the limit of the acceleration shows itself quite precisely. The tool wrote the import effortlessly; that it imported too little was noticed by the human who knows their own body of data.</p><p>Less dramatic, but telling, was the second group of findings. A static analysis found seventeen complaints in the code of the publication management, all of the boring kind: missing serialisation identifiers on the views, mutable collections passed straight out that are better copied defensively. They were resolved with fifty-six added lines. It is precisely that layer of craftsmanlike care which an assistant reliably overlooks as long as no one points it out — and which an analysis tool just as reliably finds. That the two together are better than either alone is the actual lesson.</p><p>This phase also confirmed what the view briefs had predicted. The editorial board and the history view remained custom builds; at three hundred and fifty-four lines the board is the second-largest of the seven publication views, complete with hand-wired drag-and-drop between the columns. This was no rude awakening, because it had been announced — and therein lies the whole value of a design plan that names its gaps.</p><p>What came afterwards is the actual work on an application one wants to use daily, and it fills releases thirty-two to forty: filters across several criteria, tag combinations as either AND or OR, adjustable column widths, return paths that lead back to where one came from, entries editable in place. This phase cannot be shortened, for it consists entirely of small observations one makes only once one really uses one&rsquo;s own tool. The assistant implements them quickly; it is the user who must notice them.</p><p>On balance the calculation held, but not in the way naive expectation would suggest. The path from the empty directory to a usable application was, for an individual working alongside their ongoing commitments, remarkably short. The acceleration, however, lies almost entirely in the making — in the writing of classes, views, tests and translations. It does not lie in the discovering. The truncated import, the wrong return path, the awkward handling: all of that had to be noticed by someone who knows how the thing ought to feel.</p><h2 id="an-honest-balance-sheet">An honest balance sheet</h2><p>An article that tells of a calculation tipping over owes the reader both columns of that calculation. My first draft of this chapter had a flaw I noticed only on re-reading: it listed the costs of the self-built solution meticulously and set against them a rented service whose costs remained unquantified. That is not a balance sheet, but a one-sided statement. So: both columns.</p><p>The item that cannot be argued away is maintenance. Every library ages, security vulnerabilities are reported, a new Java release appears — and no one but me will attend to it. This work does not end. It is, however, smaller than the word suggests, and precision helps more here than drama: this is an application with a single user, without an availability guarantee, without tenant separation and with few dependencies. The effort accordingly remains manageable.</p><p>More decisive than the size of this item is its nature, and here lies the actual gain. My maintenance burden obeys me. I decide when to raise a dependency; if I touch nothing for a year, the application continues to run as before. The changes of a rented service, by contrast, befall me. I can neither schedule nor decline them: prices rise, features disappear or are rebuilt, an interface is rearranged, a provider is acquired. Both are costs, yet only one kind is under my control. This distinction between costs one commands and costs that befall one is the substantive core of what I mean by sovereignty.</p><p>Which brings us to the second column, missing from the draft. ClickUp cost more than a monthly fee. It cost the daily detours from the first chapter — the makeshifts via tags, the overloaded status field, the impossibility of asking a single question cleanly. It cost the operation of an abundance of features of which I used a narrow slice. And it cost something that becomes visible only in earnest: the ability to leave. Whoever owns neither their data nor the source code cannot switch when faced with an unwelcome change, but only acquiesce. These items appear on no invoice, and that is precisely why one overlooks them for years.</p><p>The third item is dependency, and here I must examine my own principles. I like to keep close to the JDK; neither Spring nor Jakarta EE appears in this application. This is no idiosyncrasy but follows from the scope: an application server and a dependency-injection framework solve problems I do not have here — tenants, distributed transactions, interchangeable implementations within large teams. Vaadin Flow runs on an embedded Jetty, the wiring is done by ordinary Java code, and what remains can be read and surveyed in full. Vaadin, however, is anything but a small dependency — an extensive framework with a runtime world and a build tooling of its own. Whoever adopts it exchanges the fault line between Java and JavaScript for a tie to one vendor. I consider the exchange advantageous, because it removes precisely the friction that otherwise cost me most, and because the result sits with me as open source and remains compilable even if I update nothing further. An exchange it nonetheless remains. Added to this are the object store and a tool for reading JSON; the application is far from &ldquo;only the JDK&rdquo;. The aim was always restraint, not asceticism.</p><p>The fourth item concerns the reverse side of data sovereignty. My data now sit with me — and so does the responsibility for them. About backups, failing drives and a move to new hardware I never had to think with ClickUp. Now I do. At least this item is solvable and set up once, and it has a reverse side that weighs more heavily for me: a backup I make myself I can also restore myself — and I know what format it is in.</p><p>Something must also be said about the assistant that the account so far only touches upon. It has accelerated the making extraordinarily, but it possesses no judgement as to whether what has been made is correct. The truncated import from the previous station is the best example: clean code, green tests, wrong result. The tests carry this weakness within them too, for they check what the assistant understood — a misunderstanding propagates from the code into the test and is confirmed there. Whoever works this way needs means of verification from outside: static analysis, an assessment of test quality through deliberate corruption of the code, and above all one&rsquo;s own eye. This insight does not argue against the path, but it determines how one walks it.</p><p>Two points I leave without consolation, because none is due to them. The first is dependency on a single person: were I to drop out, there would be no one to maintain this application further. With a rented service that would be otherwise, and no argument helps against it. The second is the timing of this article: the application is still young. Whether it proves itself in daily use over months, whether decisions that look sensible today will come back to haunt me in half a year — that I simply do not know. Anyone claiming certainty here does not have it.</p><p>A good deal has moreover remained open. The PDF generation, one of the original motives, is not implemented and will require a further deliberate exception to the dependency principle. The commercial world was left out — rightly so, but it is missing. The import serves its purpose, yet the final switchover with a cut-off date and a reconciliation is still to come.</p><p>There remains the question of what the calculation now looks like. Its two sides run in opposite directions: the building effort was one-off and lies behind me, the rent would be recurring and would, by experience, grow. The benefit persists for as long as I write, and it consists not merely in fees saved but in something that was previously impossible — an application that mirrors my workflow instead of pressing it into a foreign model, data in a format I know, and source code that lives on without the assistant that helped write it. The calculation has therefore tipped over, but it has not become trivial. It does not read &ldquo;building your own is now free&rdquo;, but rather: the building effort has fallen so far that ongoing maintenance becomes the decisive item. And that maintenance is, unlike the changes of a foreign service, a burden one holds in one&rsquo;s own hands.</p><h2 id="outlook">Outlook</h2><p>Three steps are foreseeable, and they stand in a natural order.</p><p>The next is the final switchover. So far the new application runs alongside ClickUp, fed by an import that can be repeated at will. At some point there will need to be a cut-off date, a last reconciliation and the decision to stop feeding the old service. That moment is unspectacular and at the same time the real test: only when I use the tool without a fallback will it become apparent whether it is fit for purpose.</p><p>The second step is the PDF generation — that motive from the first chapter which has so far remained unfulfilled. Typesetting collected editions from the body of work was never conceivable with a task manager, and now it is merely a question of doing the work. It will require a further deliberate exception to my dependency principle, for the JDK brings nothing along for typesetting documents. That I make this exception and do not conceal it is preferable, to my mind, to pretending that everything can be built from on-board resources.</p><p>The third step is the commercial world I deliberately set aside in the first part: clients, remuneration models, invoices, a forecast of expected income. The lean model was designed from the outset as a genuine subset, not as a stopgap, and the transition therefore proceeds by addition rather than by rewriting. Whether this assurance holds remains to be seen — it is the kind of promise one makes to oneself and which reality occasionally declines to honour.</p><p>And beyond that? The appeal of one&rsquo;s own application lies in the fact that every small observation in daily work can lead directly to a change, rather than to an entry on a wish list administered by someone else. That is precisely the difference I was looking for at the beginning without being able to name it: I was not seeking a better ClickUp, but a tool that obeys me instead of shaping me. Whether the calculation works out in the long run I shall know in a year. That it could be drawn up at all is new — and that is the actual news of this article.</p><h2 id="related-on-this-site">Related on this site</h2><ul><li><strong><a href="/posts/from-clickup-to-vaadin-part-1-the-thesis-and-the-model/">From ClickUp to Vaadin — Part 1: The Thesis and the Model</a></strong> — the first part: the direct look into the running ClickUp workspace, the disentanglement of one status field into three orthogonal dimensions, and the hardened append-only history.</li><li><strong><a href="/posts/vaadin-flow-how-to-start/">Vaadin Flow — How to Start</a></strong> — the entry-level walkthrough for the framework used throughout this account.</li><li><strong><a href="/posts/vaadin-25-2-security-innovations/">The Security Innovations in Vaadin 25.2</a></strong> — the platform-level defaults this application inherits from Vaadin&rsquo;s own runtime.</li></ul>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/08/from-clickup-to-vaadin-part-2-hero.jpg" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/08/from-clickup-to-vaadin-part-2-hero.jpg"/><enclosure url="https://svenruppert.com/images/2026/08/from-clickup-to-vaadin-part-2-hero.jpg" type="image/jpeg" length="0"/></item><item><title>From ClickUp to Vaadin — Part 1: The Thesis and the Model</title><link>https://svenruppert.com/posts/from-clickup-to-vaadin-part-1-the-thesis-and-the-model/</link><pubDate>Fri, 21 Aug 2026 09:00:00 +0200</pubDate><author>sven.ruppert@gmail.com (Sven Ruppert)</author><dc:creator>Sven Ruppert</dc:creator><guid isPermaLink="true">https://svenruppert.com/posts/from-clickup-to-vaadin-part-1-the-thesis-and-the-model/</guid><description>Managing a decade of writing in ClickUp taught me exactly where a foreign task tracker starts working against you: sixteen status values in one field, four content levels concealed in a naming convention, and a monthly bill for an abundance I never used. This first part is the analysis and the model — the direct look into the running workspace, the disentanglement of one status field into three orthogonal dimensions (acquisition, editorial work, production), the append-only history hardened by a sequence number rather than a timestamp, and the seven-view draft with its honest seams. Data sovereignty is no longer a privilege of large organisations with development departments — AI-assisted development makes the tailored, self-built option affordable for the individual, and the thesis of this two-part account is that the calculation has tipped over.</description><content:encoded>&lt;![CDATA[<h2 id="the-motivation">The Motivation</h2><p>For many years I have managed my writing in ClickUp: the topics I am working on, the individual articles, their translations, the places where they appear, and the commercial handling that comes with them. Over time a considerable body of data has accumulated there, and in many respects the tool does what it should. It was not the dissatisfaction of a single grievance that made me reconsider, but the slow accumulation of several small frictions, until the question became unavoidable of whether things had to continue in this way.</p><p>The first friction is fundamental in nature. My data resides in a cloud on the far side of the Atlantic, on servers whose legal and political framework I do not determine. For notes this may be a matter of indifference; for the collected work of many years, together with the names of clients and the terms behind them, it is not. The wish to regain sovereignty over this body of data has, over time, grown from a diffuse unease into a clear requirement.</p><p>The second friction is, in a sense, the opposite of a shortcoming. ClickUp grows more powerful year by year and takes on ever more of what teams might need — only I need the least of it. I pay for and operate a growing abundance of which I use a narrow slice, and with every new feature the tool becomes a little heavier than it would need to be for my purpose. Bound up directly with this is the third friction, the plainest of all: it costs money, month after month, for a benefit that bears no good relation to it.</p><p>The fourth friction is the truly telling one, for it is not one of price but of fit. My workflow calls for distinctions that ClickUp&rsquo;s model does not provide for, and so I make do, as one does: with tags, with additional fields, with a single status field onto which I load more meanings than one field can bear. It works, in the way a tool works when it is repurposed — not wrongly, but always slightly against the grain. It was precisely here that the suspicion took root that my workflow possessed a structure which the foreign tool did not reveal to me but concealed.</p><p>And finally, I want to make something of this data that goes beyond merely managing it: to compile it into PDFs, to bundle it into collected editions, to publish from the body of work itself. That is not a task a task manager is meant for, and the longer I thought about it, the clearer it became that I was not looking for a better ClickUp, but for something else.</p><p>So at the outset there stood not a decision, but a sum of observations — and the question of whether one continues to endure the foreign software because building one&rsquo;s own seemed too expensive, or whether something about that calculation has meanwhile changed.</p><h2 id="the-thesis">The Thesis</h2><p>Until recently I would have answered this question quickly, and to the detriment of building my own. A bespoke application that mirrored my workflow exactly and kept my data on my own ground was simply uneconomical for an individual. The design of the model, the interface, the persistence, the many small paths in between — all of this costs time, and time is the most expensive item of all for an individual. So one endures the foreign software together with its proliferation of features, because the alternative does not pay off. That calculation held true for years.</p><p>What has changed is not my aspiration, but the price of fulfilling it. An AI-assisted workflow shifts the effort that hitherto argued against building one&rsquo;s own so far that the calculation tips over. Not because a machine relieves me of the thinking — the substantive decisions remain mine — but because the path from the idea to a working draft is shortened so greatly that the tailored, self-built option suddenly becomes worthwhile. And precisely therein lies the actual thesis of this article: data sovereignty is no longer a privilege of large organisations with their own development departments. AI-assisted development makes one&rsquo;s own, precisely fitting application affordable for the individual as well.</p><p>This thesis has three limbs that ought to be kept apart. Data sovereignty is the why — the reason I take this path at all. AI-assisted development is what is economically new, and it is what makes the path passable in the first place; it is the answer to the question of how this should be possible for an individual at all. And the with-what is a tool with which such an application can be built consistently in a single language, from the data model through to the interface, without the otherwise customary break between the two worlds — more on that later, at the point where it shows itself of its own accord.</p><p>At this point an objection presses forward that I would rather voice myself before a reader formulates it. I speak of sovereignty and independence — and at the same time I let an AI assistant from a US provider design my model and build my interface. Is that not a contradiction that undermines the whole account? It is not, as soon as one looks closely at what is meant to be sovereign here. Sovereignty means authority over the place, the format and the ownership of my data, as well as over the source code that ultimately runs on my own infrastructure and lives on there at will — without the assistant that helped to write it. The AI is a tool of production, not a component of operation. It stands at the beginning, as the carpenter stands by the house that is afterwards inhabited without them. Whoever does not draw this distinction between production and operation sees a contradiction; whoever draws it sees the division of labour that carries the whole path in the first place.</p><p>Understood in this way, the AI is not the adversary of sovereignty but its enabler — and what remains at the end belongs to me: the data, the model, the source code. What this path looked like in detail is the subject of the rest of this article.</p><h2 id="the-process-as-evidence">The Process as Evidence</h2><p>Before I describe the individual stations of this path, I must speak about the manner in which it was travelled — for that manner is not incidental, but is itself the argument. An article that claims AI-assisted development makes the tailored, self-built option affordable could fill that claim with figures and promises. It is more convincing to demonstrate the process and let readers judge for themselves. That is precisely the intention: the path is the evidence.</p><p>At the outset there stood not my description of ClickUp, but the direct look into it. Through a tool connection the assistant had access to my running workspace and read it itself — the lists, the custom fields, the status values, the actual tasks. That is a fine but decisive difference. Had I described the structure from memory, a smoothed, already-interpreted version would have emerged; as it was, however, the analysis began from reality, together with its inconsistencies and with what I myself had ceased to notice consciously over the years.</p><p>What followed was not a dictation, but a conversation. I made the substantive decisions — what a topic is, how a status is to be understood, what may fall away — while the assistant proposed, arranged, asked questions and at one point even hardened the model against my first draft. The roles remained clearly distributed throughout: I am the architect who knows what the application is to accomplish; the tool is the fast, tireless fellow-thinker that supplies drafts, formulates objections and takes on the implementation. From this division of labour arises the acceleration that makes the calculation mentioned at the outset tip over.</p><p>Honesty requires acknowledging that this process does not proceed without friction. The assistant errs, at times gets ahead of itself, reads in something I did not intend, and has to be reined back in. Yet precisely therein lies the confirmation of the division of labour: it takes the human judgement that holds the direction and notices the misstep. The tool accelerates the making; it does not replace the one who knows where things are meant to go. What this path looked like in detail is the subject of the stations that follow — and the first leads to the origin of it all: into the running workspace itself.</p><h2 id="taking-stock-of-the-running-system">Taking stock of the running system</h2><p>At the outset, as announced, there stood not my account but direct access. Through the tool connection, the assistant read my running workspace at first hand — not an exported copy, not a memory-borne retelling, but the system in which the work actually resided. This circumstance matters more than it first appears. Anyone who describes their own practice from memory already supplies an interpretation; the inconsistencies are smoothed away, the habits straightened, what grew over time forced into an order it never possessed. The look into the running system, by contrast, shows the holdings as they are, together with everything that had crept in unnoticed over the years.</p><p>What showed itself was, at first, of disarming plainness: a single list. No web of folders, no hierarchy of sub-lists, but a flat sequence of tasks whose names obeyed a strict pattern — a prefix of hyphen-separated parts that carried the actual structure. Only this prefix betrayed that the seemingly equal-ranking entries in truth denoted different things and lay at different levels. The structure was present, but it did not reside in the system; it resided in a naming convention I had imposed upon myself and half forgotten over time.</p><p>More revealing than the list was the individual task, once one expanded it in full. A name, a description, a status value, a series of labels — and, alongside these, custom fields I had once created to wrest from the tool what it did not offer of its own accord. It is precisely in this abundance that the value of direct examination lies: it brings home how much domain meaning is dammed up in a single record that the foreign tool carries as an unadorned line. What the model was later to prise apart into levels and dimensions still lay here one atop another, folded together into a single entry.</p><p>Part of the honesty of this approach is an inconsistency that revealed itself along the way. The obvious route to gathering the custom fields would have been the call provided for exactly that purpose — yet for this workspace it returned an unusable result, in which even the fields&rsquo; mandatory flag came back empty. What proved reliable instead was the detour by way of a single, named task, retrieved in full detail, from which the field structure could be read off beyond doubt. This is a small observation, but a telling one: the direct look into the living system uncovers not only one&rsquo;s own structure but also the idiosyncrasies of the tool with which one reads it out. Had I relied on description, both would have remained hidden.</p><p>At the end of this stocktaking there stood not a design but a sharpened view of what was there: a flat list that concealed a multi-level structure, and tasks that carried far more meaning than their plain appearance would suggest. One field among them was to prove especially telling — that single status field into which too much had migrated over the years. To it the following stage is devoted.</p><h2 id="analysis-the-conflated-status-field">Analysis: the conflated status field</h2><p>The first real step was not a design but an inventory — and it began where the data actually reside. The assistant read out my running workspace itself: a single list whose tasks bear names like &ldquo;Blog – Navigation – Koppelnavigation&rdquo; — German for coupled navigation. What looks at first glance like a plain task list conceals, on closer inspection, a structure that ClickUp nowhere states.</p><p>For ClickUp knows only one level: the task. Everything sits within it at once. In truth, however, my work is organised into four levels that surface only in the name prefix. The topic — the &ldquo;issue&rdquo; — is the subject-matter bracket; beneath it stands the part as a language-neutral instalment; to it belong the language versions, one per language; and only the publication binds a version to a concrete place. These four levels are compressed into the flat task and must be reconstructed from it, not merely carried over.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-1-ch05-bild-01.svg" alt="One ClickUp task vs. the four-level target structure" loading="lazy" decoding="async"/><p><em>Figure 1: A single ClickUp task versus the four-level target structure.</em></p><p>The more telling discovery, though, concerned the status. ClickUp kept a single status field, and into this field I had, over the years, pressed sixteen values: from<em>backlog</em>,<em>planned</em>,<em>in progress</em> and<em>review</em>, through<em>offered</em>,<em>accepted</em> and<em>in pipeline</em>, to<em>scheduled</em>,<em>draft published</em>,<em>published</em>,<em>update required</em>,<em>ready for billing</em>,<em>paid</em>,<em>on hold</em>,<em>skipped</em> and<em>closed</em>. Taken on its own, each value makes sense. Side by side in one field, however, they answer quite different questions — and that is precisely the problem.</p><p>For within this single field there are three mutually independent kinds of progress. The first is the editorial work state: how far has the text come? The second is the production of the publication: is it planned, prepared, published? The third is acquisition: has the work been requested, offered, agreed? These three questions are orthogonal — they stand perpendicular to one another and can be answered independently. An agreement may long since be in place while the text is still being written and the publication not yet scheduled. Force all three into a single field, and one of the kinds of progress must always displace the other two.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-1-ch05-bild-02.svg" alt="One conflated status field, decomposed into three orthogonal dimensions" loading="lazy" decoding="async"/><p><em>Figure 2: One conflated status field, decomposed into three orthogonal dimensions.</em></p><p>Two values expose the overloading with particular clarity.<em>paid</em> is not a state of the content at all, but a property of the invoice; it had merely strayed into the status field for want of a home of its own. And<em>on hold</em> is no phase whatsoever, but a resting that cuts across all three — enter it, and it overwrites the actual progress and effectively erases it. Both are unmistakable signs that a single field was bearing loads never intended for it.</p><p>The consequences are felt in daily use. Whenever I wanted to know which parts were in copy-editing, production and sales states mixed into the answer; a clean view of a single question was not to be had. The foreign tool did not show me my workflow, it concealed it behind a single, overladen column. With that, the actual task lay plainly exposed: to decompose the one field into three clear, independent dimensions — and that very disentangling is the subject of the stations that follow.</p><h2 id="take-over-decompose-refine-strike-out">Take over, decompose, refine, strike out</h2><p>The analysis had named the task, not solved it. Sixteen values lay in a single field, three questions were concealed within it, and between that insight and a sound model stood the actual work: to interrogate each individual value as to where it belongs. This work falls into four operations, and the heading names them in the order in which they pressed themselves upon me — take over, decompose, refine, strike out.</p><p>The first operation is the easiest, because it overturns nothing. A portion of the values did describe what they purported to describe: the progress of the editorial text.<em>backlog</em>,<em>in planning</em>,<em>in progress</em> and<em>review</em> are genuine working states, and they migrate unchanged into the editorial dimension, which the model carries as the enumeration<code>EditorialState</code>, running from<code>BACKLOG</code> through<code>IN_PLANNING</code>,<code>IN_PROGRESS</code> and<code>REVIEW</code> to the terminal states<code>DONE</code>,<code>SKIPPED</code> and<code>CANCELLED</code>. For these values the migration meant no interpretation, merely plain adoption.</p><p>The second operation is the true heart of the whole undertaking. For several values that stood companionably beside the working states in the ClickUp field were in truth answering an altogether different question.<em>offered</em>,<em>accepted</em> and<em>in pipeline</em> say nothing about the text but about acquisition — about whether a piece of work has been requested, offered or agreed. They belong on an axis of their own, which the model maintains as<code>AcquisitionStatus</code> with the values<code>REQUESTED</code>,<code>OFFERED</code>,<code>ACCEPTED</code> and<code>REVIEW</code> up to its own terminal states. The same holds for<em>scheduled</em> and<em>update required</em>: they concern the production of the publication, not the text, and find their place on the third axis,<code>ProductionStatus</code>, with<code>PLANNED</code>,<code>PREPARED</code>,<code>PUBLISHED</code> and<code>UPDATE_NEEDED</code>. What lay compressed into a single field is here distributed across three mutually perpendicular dimensions — this is the decomposition from which the entire argument proceeds.</p><p>The third operation concerns the cases in which a single value does not simply relocate but requires clarification. This shows most clearly with<em>published</em>. That one value bears two meanings at once: it reports that the text is finished and that the publication has appeared — an editorial and a production statement in a single word. In the decomposition it must divide, and the editorial side falls, consistently, upon<code>DONE</code>. Part of the refinement, too, is the unification of the spellings that had accumulated over the years: whether I had noted<em>to do</em>,<em>todo</em> or<em>planning</em>, whether<em>cancelled</em> or<em>canceled</em> — the mapping reduces these variants to a single canonical target state each, and only thereby renders the collection amenable to evaluation. And because no design foresees reality in full, every unanticipated value issues not into nothing but, cautiously, into the initial state<code>BACKLOG</code>, from which it remains visible and can be put right by hand.</p><p>How closely this refinement sits to the code is shown by the case distinction with which the import later maps every source value onto the editorial axis:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="cm">/** Distributes the conflated ClickUp status onto the editorial state. */</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="kd">public</span><span class="w"/><span class="kd">static</span><span class="w"/><span class="n">EditorialState</span><span class="w"/><span class="nf">mapStatus</span><span class="p">(</span><span class="n">String</span><span class="w"/><span class="n">clickup</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">String</span><span class="w"/><span class="n">s</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">clickup</span><span class="w"/><span class="o">==</span><span class="w"/><span class="kc">null</span><span class="w"/><span class="o">?</span><span class="w"/><span class="s">""</span><span class="w"/><span class="p">:</span><span class="w"/><span class="n">clickup</span><span class="p">.</span><span class="na">toLowerCase</span><span class="p">().</span><span class="na">strip</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="w"/><span class="k">switch</span><span class="w"/><span class="p">(</span><span class="n">s</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">case</span><span class="w"/><span class="s">"in progress"</span><span class="p">,</span><span class="w"/><span class="s">"in-progress"</span><span class="p">,</span><span class="w"/><span class="s">"progress"</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">EditorialState</span><span class="p">.</span><span class="na">IN_PROGRESS</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">case</span><span class="w"/><span class="s">"review"</span><span class="p">,</span><span class="w"/><span class="s">"in review"</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">EditorialState</span><span class="p">.</span><span class="na">REVIEW</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">case</span><span class="w"/><span class="s">"planning"</span><span class="p">,</span><span class="w"/><span class="s">"in planning"</span><span class="p">,</span><span class="w"/><span class="s">"planned"</span><span class="p">,</span><span class="w"/><span class="s">"to do"</span><span class="p">,</span><span class="w"/><span class="s">"todo"</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">EditorialState</span><span class="p">.</span><span class="na">IN_PLANNING</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">case</span><span class="w"/><span class="s">"done"</span><span class="p">,</span><span class="w"/><span class="s">"complete"</span><span class="p">,</span><span class="w"/><span class="s">"completed"</span><span class="p">,</span><span class="w"/><span class="s">"closed"</span><span class="p">,</span><span class="w"/><span class="s">"published"</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">EditorialState</span><span class="p">.</span><span class="na">DONE</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">case</span><span class="w"/><span class="s">"skipped"</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">EditorialState</span><span class="p">.</span><span class="na">SKIPPED</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">case</span><span class="w"/><span class="s">"cancelled"</span><span class="p">,</span><span class="w"/><span class="s">"canceled"</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">EditorialState</span><span class="p">.</span><span class="na">CANCELLED</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">default</span><span class="w"/><span class="o">-&gt;</span><span class="w"/><span class="n">EditorialState</span><span class="p">.</span><span class="na">BACKLOG</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">};</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>The spellings drawn together in each branch are the refinement; the final branch is the cautious fall back into the backlog. At the same time the excerpt names its own limit: it knows the editorial axis alone. Why the two other dimensions must arrive empty from the source is the subject of the second part.</p><p>The fourth operation, finally, removes what has no business on any of the three axes.<em>paid</em> is not a state of a piece of content but a property of the invoice; it had strayed into the status field only for want of a home of its own, and it leaves the content dimensions without replacement.<em>ready for billing</em> shares this fate, for readiness to invoice, likewise, is a matter of the invoice, not of the text. And<em>on hold</em>, the second tell-tale value from the analysis, is no phase at all but a resting that lies athwart all three; were it entered as a status value, it would overwrite the actual progress and, in effect, erase it. It is therefore struck from the status axis rather than allowed to falsify it.</p><p>At the end of these four operations there stands a mapping table: on the left the accumulated values of the source, on the right their place in one of the three dimensions — or the reasoned finding that they have no place there. This table is more than an overview. It is the bridge between the analysis and the code, and it returns in two guises: as the hardened model in the following stage, and as an executable case distinction in the import of the second part — though the import executes only its editorial column; why the other two dimensions start empty is shown in the second part. The observation has thereby become a prescription that can be tested.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-1-ch06-bild-01.png" alt="How sixteen ClickUp status values were resolved by the four operations" loading="lazy" decoding="async"/><p><em>Figure 1: How the sixteen ClickUp status values were resolved by the four operations — taken over onto the editorial dimension, decomposed onto acquisition and production, the split refinement of &ldquo;published&rdquo;, and the strikings-out from the status axis.</em></p><h2 id="model-hardening-the-tool-thinks-along">Model hardening: the tool thinks along</h2><p>The analysis ended with a clear task: to decompose the one overloaded field into three independent dimensions. But a decomposition alone is not enough — what matters is how one anchors it in the code so that it holds. It was precisely here that something happened which I regard as the true essence of AI-assisted work: from a passing observation there grew a structural improvement that I had not even foreseen in my first draft.</p><p>The starting point was a plain concern that I voiced in conversation. If each of the three dimensions keeps its history as a list of status changes, then that list lies open — and an open list can be reordered by accident, extended in the wrong place, or relieved of entries. For an audit trail meant to prove in what order a state has developed, that is a dangerous weakness. What began as a small remark led to a hardening that rests on two pillars.</p><p>The first pillar makes the order independent of the list position. Every status change carries a strictly ascending sequence number, and it is not the timestamp that decides the order, but this number. The timestamp remains descriptive — for clocks jump, and two events might bear the same instant.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"/><span class="kd">record</span><span class="nc">StatusChange</span><span class="o">&lt;</span><span class="n">S</span><span class="w"/><span class="kd">extends</span><span class="w"/><span class="n">Enum</span><span class="o">&lt;</span><span class="n">S</span><span class="o">&gt;&gt;</span><span class="p">(</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kt">long</span><span class="w"/><span class="n">sequence</span><span class="p">,</span><span class="w"/><span class="n">S</span><span class="w"/><span class="n">from</span><span class="p">,</span><span class="w"/><span class="n">S</span><span class="w"/><span class="kd">to</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">actor</span><span class="p">,</span><span class="w"/><span class="n">Instant</span><span class="w"/><span class="n">timestamp</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">public</span><span class="w"/><span class="n">StatusChange</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">if</span><span class="w"/><span class="p">(</span><span class="n">sequence</span><span class="w"/><span class="o">&lt;</span><span class="w"/><span class="n">0</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">throw</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">IllegalArgumentException</span><span class="p">(</span><span class="s">"sequence must be &gt;= 0, was "</span><span class="w"/><span class="o">+</span><span class="w"/><span class="n">sequence</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">Objects</span><span class="p">.</span><span class="na">requireNonNull</span><span class="p">(</span><span class="n">from</span><span class="p">,</span><span class="w"/><span class="s">"from"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">Objects</span><span class="p">.</span><span class="na">requireNonNull</span><span class="p">(</span><span class="kd">to</span><span class="p">,</span><span class="w"/><span class="s">"to"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">Objects</span><span class="p">.</span><span class="na">requireNonNull</span><span class="p">(</span><span class="n">timestamp</span><span class="p">,</span><span class="w"/><span class="s">"timestamp"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>That this event is a record seems incidental, yet it carries the entire guarantee: a record is immutable, so that a change once written down can no longer be altered afterwards — neither the sequence number nor the source or target state. The tamper-resistance of the history therefore rests not on conventions but on a property of the language. The application compiles against Java 26 and may of course make use of such means.</p><p>The second pillar prevents reordering from the outset by not exposing the list at all. In its place stands a generic history that keeps the events private and offers only two capabilities to the outside: the reading<code>events()</code>, which returns an immutable view, and the appending<code>record</code>. There is no inserting, removing or reordering.</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="kd">class</span><span class="nc">StatusHistory</span><span class="o">&lt;</span><span class="n">S</span><span class="w"/><span class="kd">extends</span><span class="w"/><span class="n">Enum</span><span class="o">&lt;</span><span class="n">S</span><span class="o">&gt;&gt;</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">private</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="n">S</span><span class="w"/><span class="n">initial</span><span class="p">;</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">private</span><span class="w"/><span class="kd">final</span><span class="w"/><span class="n">List</span><span class="o">&lt;</span><span class="n">StatusChange</span><span class="o">&lt;</span><span class="n">S</span><span class="o">&gt;&gt;</span><span class="w"/><span class="n">events</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">ArrayList</span><span class="o">&lt;&gt;</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">public</span><span class="w"/><span class="nf">StatusHistory</span><span class="p">(</span><span class="n">S</span><span class="w"/><span class="n">initial</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">this</span><span class="p">.</span><span class="na">initial</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">Objects</span><span class="p">.</span><span class="na">requireNonNull</span><span class="p">(</span><span class="n">initial</span><span class="p">,</span><span class="w"/><span class="s">"initial"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">public</span><span class="w"/><span class="n">S</span><span class="w"/><span class="nf">current</span><span class="p">()</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="w"/><span class="n">events</span><span class="p">.</span><span class="na">isEmpty</span><span class="p">()</span><span class="w"/><span class="o">?</span><span class="w"/><span class="n">initial</span><span class="w"/><span class="p">:</span><span class="w"/><span class="n">events</span><span class="p">.</span><span class="na">get</span><span class="p">(</span><span class="n">events</span><span class="p">.</span><span class="na">size</span><span class="p">()</span><span class="w"/><span class="o">-</span><span class="w"/><span class="n">1</span><span class="p">).</span><span class="na">to</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">public</span><span class="w"/><span class="kt">void</span><span class="w"/><span class="nf">record</span><span class="p">(</span><span class="n">S</span><span class="w"/><span class="kd">to</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">actor</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">record</span><span class="err">(</span><span class="nc">to</span><span class="p">,</span><span class="w"/><span class="n">actor</span><span class="p">,</span><span class="w"/><span class="n">Instant</span><span class="p">.</span><span class="na">now</span><span class="p">());</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">public</span><span class="w"/><span class="kt">void</span><span class="w"/><span class="nf">record</span><span class="p">(</span><span class="n">S</span><span class="w"/><span class="kd">to</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">actor</span><span class="p">,</span><span class="w"/><span class="n">Instant</span><span class="w"/><span class="n">timestamp</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">Objects</span><span class="p">.</span><span class="na">requireNonNull</span><span class="p">(</span><span class="kd">to</span><span class="p">,</span><span class="w"/><span class="s">"to"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">Objects</span><span class="p">.</span><span class="na">requireNonNull</span><span class="p">(</span><span class="n">timestamp</span><span class="p">,</span><span class="w"/><span class="s">"timestamp"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">S</span><span class="w"/><span class="n">from</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">current</span><span class="p">();</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">events</span><span class="p">.</span><span class="na">add</span><span class="p">(</span><span class="k">new</span><span class="w"/><span class="n">StatusChange</span><span class="o">&lt;&gt;</span><span class="p">(</span><span class="n">events</span><span class="p">.</span><span class="na">size</span><span class="p">(),</span><span class="w"/><span class="n">from</span><span class="p">,</span><span class="w"/><span class="kd">to</span><span class="p">,</span><span class="w"/><span class="n">actor</span><span class="p">,</span><span class="w"/><span class="n">timestamp</span><span class="p">));</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="kd">public</span><span class="w"/><span class="n">List</span><span class="o">&lt;</span><span class="n">StatusChange</span><span class="o">&lt;</span><span class="n">S</span><span class="o">&gt;&gt;</span><span class="w"/><span class="nf">events</span><span class="p">()</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">return</span><span class="w"/><span class="n">List</span><span class="p">.</span><span class="na">copyOf</span><span class="p">(</span><span class="n">events</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>The whole point sits in this small type. Because<code>record</code> derives the source value<code>from</code> itself from the current state and assigns the next sequence number, neither an inconsistent transition can be injected nor the order falsified. And because<code>current()</code> always reads the current state as the target of the last event, rather than storing it separately, it can never diverge from the history. What was previously a matter of discipline — please do not reorder the list — is now a property of the type. These guarantees are not a mere promise of the prose; the model is accompanied by tests such as<code>StatusHistoryTest</code> and<code>StatusChangeTest</code> that pin down exactly these.</p><p>The real gain shows itself when the same history serves all three dimensions. The dimensions themselves are plain enumerations — production, for instance, knows four values:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"/><span class="kd">enum</span><span class="w"/><span class="n">ProductionStatus</span><span class="w"/><span class="p">{</span><span class="w"/><span class="n">PLANNED</span><span class="p">,</span><span class="w"/><span class="n">PREPARED</span><span class="p">,</span><span class="w"/><span class="n">PUBLISHED</span><span class="p">,</span><span class="w"/><span class="n">UPDATE_NEEDED</span><span class="w"/><span class="p">}</span></span></span></code></pre></div></div><p>The editorial work state and acquisition each carry seven values together with an<code>isTerminal()</code> that marks their terminal states<code>DONE</code>,<code>SKIPPED</code> and<code>CANCELLED</code>. On the<code>Part</code> hangs the editorial state alone; the<code>Publication</code>, by contrast, carries two orthogonal lifecycles at once — production and acquisition — each with its own hardened history. It is here that the disentangling from the analysis becomes executable code:</p><div class="code-wrap"><div class="code-wrap__bar"><span class="code-wrap__lang">java</span><button type="button" class="code-wrap__copy" aria-label="Copy code">Copy</button></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"/><span class="nf">Publication</span><span class="p">(</span><span class="n">UUID</span><span class="w"/><span class="n">id</span><span class="p">,</span><span class="w"/><span class="n">LanguageVersion</span><span class="w"/><span class="n">version</span><span class="p">,</span><span class="w"/><span class="n">PublicationPlace</span><span class="w"/><span class="n">place</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">this</span><span class="p">.</span><span class="na">id</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">Objects</span><span class="p">.</span><span class="na">requireNonNull</span><span class="p">(</span><span class="n">id</span><span class="p">,</span><span class="w"/><span class="s">"id"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">this</span><span class="p">.</span><span class="na">version</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">Objects</span><span class="p">.</span><span class="na">requireNonNull</span><span class="p">(</span><span class="n">version</span><span class="p">,</span><span class="w"/><span class="s">"version"</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">this</span><span class="p">.</span><span class="na">place</span><span class="w"/><span class="o">=</span><span class="w"/><span class="n">checkLanguageRule</span><span class="p">(</span><span class="n">version</span><span class="p">,</span><span class="w"/><span class="n">Objects</span><span class="p">.</span><span class="na">requireNonNull</span><span class="p">(</span><span class="n">place</span><span class="p">,</span><span class="w"/><span class="s">"place"</span><span class="p">));</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">this</span><span class="p">.</span><span class="na">production</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">StatusHistory</span><span class="o">&lt;&gt;</span><span class="p">(</span><span class="n">ProductionStatus</span><span class="p">.</span><span class="na">PLANNED</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="k">this</span><span class="p">.</span><span class="na">acquisition</span><span class="w"/><span class="o">=</span><span class="w"/><span class="k">new</span><span class="w"/><span class="n">StatusHistory</span><span class="o">&lt;&gt;</span><span class="p">(</span><span class="n">AcquisitionStatus</span><span class="p">.</span><span class="na">REQUESTED</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="kd">public</span><span class="w"/><span class="kt">void</span><span class="w"/><span class="nf">changeProductionStatus</span><span class="p">(</span><span class="n">ProductionStatus</span><span class="w"/><span class="kd">to</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">actor</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">production</span><span class="p">.</span><span class="na">record</span><span class="p">(</span><span class="kd">to</span><span class="p">,</span><span class="w"/><span class="n">actor</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/></span></span><span class="line"><span class="cl"><span class="kd">public</span><span class="w"/><span class="kt">void</span><span class="w"/><span class="nf">changeAcquisitionStatus</span><span class="p">(</span><span class="n">AcquisitionStatus</span><span class="w"/><span class="kd">to</span><span class="p">,</span><span class="w"/><span class="n">String</span><span class="w"/><span class="n">actor</span><span class="p">)</span><span class="w"/><span class="p">{</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="w"/><span class="n">acquisition</span><span class="p">.</span><span class="na">record</span><span class="p">(</span><span class="kd">to</span><span class="p">,</span><span class="w"/><span class="n">actor</span><span class="p">);</span><span class="w"/></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><p>Two independent histories on a single object — that is the decomposition which was impossible in the conflated ClickUp field, yet here stands as a mere matter of course. In passing, the constructor watches over the language rule: a publication may point only to a place whose supported languages include the language of the version; otherwise it does not come into being at all.</p><p>In retrospect this step is, for me, the most convincing evidence of the thesis set out at the beginning. Not because a machine devised the model — the substantive direction remained mine — but because an attentive fellow-thinker took up a passing concern and translated it into a construction that hardens the model at a real weak point. That is what &ldquo;the tool thinks along&rdquo; means in concrete terms. How a coherent concept grows out of this hardened model, and the interface out of that, is what the following stations show.</p><h2 id="where-the-data-reside-the-object-graph">Where the data reside: the object graph</h2><p>A word on persistence, which seems inconspicuous beside model and interface, yet is essential to the concern of the whole. The data do not reside in a relational database, but as an object graph that EclipseStore writes into files on my own disk. This dispenses with the entire intermediate layer that otherwise mediates between objects and tables: no schema, no mapping, no migration scripts, no database server wanting to be maintained and backed up. For a body of data of this order — a single user, a few hundred topics — that is simply the leaner solution. Above all, though, it makes data sovereignty tangible: what belongs to me sits as a directory on my disk, copyable, backupable, portable. An object store admittedly ties me more closely to Java than an open tabular format would; for an application that consists entirely of Java in any case, I consider that price appropriate.</p><h2 id="from-the-model-to-the-concept">From the model to the concept</h2><p>A hardened data model is not yet an application. Between the classes and the interface lie two steps that are easily skipped and then dearly paid for: condensing the model into a coherent concept, and deriving the screens from the working processes. Both steps came about in the same manner as everything before them — in conversation, with a clear division of roles.</p><p>The domain concept summarises what the model expresses only in types: the four content levels and the fact that they must be reconstructed from the flat ClickUp task; the three status dimensions and their carriers; the history as a chain that proves; the language rule, according to which a publication may point only to a place whose languages include that of the version. Above all, however, it records what does<em>not</em> belong to it. The entire commercial world — legal entities, remuneration models, invoices, forecasts — was deliberately left out. This omission was not economising in the wrong place, but method: a model that seeks to depict everything at once is never finished, and for the first working state it is the core that counts.</p><h2 id="the-process-catalogue-views-follow-from-processes">The process catalogue: views follow from processes</h2><p>The second step was the more important one, and it is the one I shall never omit again. It would have been obvious to derive the screens directly from the data model — a form for every class, a table for every list. Everyone knows the result of such derivations: interfaces that display everything and support nothing. Instead, a catalogue of working processes came first: twenty procedures with fixed identifiers, from &ldquo;create topic&rdquo; through &ldquo;advance the editorial state&rdquo; and &ldquo;manage acquisition&rdquo; to the three steps of the import. Each entry names its purpose, the level affected and the state transition it triggers.</p><p>Only from these processes were the views then derived, and in such a way that every screen explicitly records which processes it serves. This tying-back sounds like bureaucracy, yet it proves to be the sharpest tool against superfluous interfaces: a screen that cannot invoke any process is dispensable; a process without a screen is a gap. Seven views remained, along with the question of how they stand in relation to one another.</p><h2 id="the-draft-seven-views-and-their-limits">The draft: seven views and their limits</h2><p>At this point the tool came forward for the first time with a limitation rather than a possibility — and that is worth reporting. Vaadin brings with it a rich stock of building blocks, and for most screens the assignment was straightforward: tables, form layouts, selection fields, tabs, date pickers. For two views, however, there is no ready-made block. The editorial board with its columns per work state is not a standard component, and neither is a timeline for the history view. Both had to be assembled from layouts, drag-and-drop and custom cards. It would have been tempting to keep quiet about this; but a design plan that does not identify such places merely shifts the effort into the implementation, where it makes itself felt more unpleasantly.</p><p>From the view briefs there finally emerged an interactive draft. I handed over the four documents — type sketches, concept, process catalogue and view briefs — to a design tool with the plain request to make from them a first draft of an elegant working environment intended for daily use. What came back surprised me on one point. The draft had not merely drawn the screens, but had understood the logic behind them: the history view carried the note that the ordering rests on the sequence number and not on the timestamp, and the production dimension managed without the terminal states of the others, offering instead a specially labelled return step.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-1-ch11-bild-01.png" alt="Publication view — acquisition and production as two equal columns" loading="lazy" decoding="async"/><p><em>Figure 1: The publication view — acquisition and production as two equal columns.</em></p><p>This one screen is the culmination of the entire path. On the left acquisition, on the right production, equal in size, equal in weight, each with its own current state and its own history. What was impossible in the ClickUp field stands here as a matter of course: an agreement has been given while production is still under way — two truths side by side, neither displacing the other. One does not have to explain the disentangling; one sees it.</p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-1-ch11-bild-02.png" alt="Topic workspace — a topic with its parts, tags and the recorded ClickUp origin" loading="lazy" decoding="async"/><p><em>Figure 2: The topic workspace — a topic with its parts, tags and the recorded ClickUp origin.</em></p><figure><img src="/images/2026/08/from-clickup-to-vaadin-part-1-ch11-bild-03.png" alt="History view — the append-only chain, ordered by sequence number" loading="lazy" decoding="async"/><p><em>Figure 3: The history view — the append-only chain, ordered by sequence number, with actor and timestamp.</em></p><p>A word of honesty about the limit of this step. The draft is a free front-end mock-up, not Vaadin code; the design tool does not know Vaadin&rsquo;s stock of components and cannot query it either. Between draft and implementation there therefore runs a seam that no one should talk away — the view briefs, with their assignment to concrete building blocks, are precisely the thread with which it is later sewn up. How this seam was actually closed, and what came to light in the process, is the subject of the second part.</p><h2 id="does-this-hold-up-against-reality">Does this hold up against reality?</h2><p>At the end of this first part there stands a model, and models have the disagreeable property of always looking better under contemplation than in use. Before the first line of the application is written, the question that gives this stage its name is therefore in order. Three objections strike me as the most serious, and not one of them can be disposed of by further reflection.</p><p>The first concerns the foundation. The entire model is derived from a single workspace, that of a single author with his accumulated habits. What presented itself there as three orthogonal dimensions could equally well be the idiosyncrasy of an individual case. I do not claim to have found a general order for publishing work; I claim to have found a sound one for these particular holdings. The difference is considerable, and anyone treading the same path should begin with their own stocktaking and not with mine.</p><p>The second objection is directed against the disentanglement itself. That acquisition, editorial work and production proceed independently of one another is an assertion about reality, not a deduction from it. It rests on the observation that values which never excluded one another lay side by side in the source. Yet whether the three axes really prove independent in daily use — or whether a tacit coupling establishes itself between them after all, which then once again takes refuge in comment fields and labels — is decided by no act of modelling, but by use alone.</p><p>The third objection is the most uncomfortable, because it is already settled. The two newly won dimensions arrive from the migration empty. What was never gathered cannot be filled in retrospectively, and so the holdings taken over begin in initial states that say nothing about their past. The gain from the disentanglement lies entirely in the future. Such a promise may be redeemed or may come to nothing, and how it turned out belongs in the reckoning, not in the design.</p><p>It remains to record what follows from this examination. A model that has to hold its own only against itself always wins. The sole test that counts consists in sending the real holdings through it, working the views, and observing at which point the order tears. That is precisely the subject of the second part — not as proof of a successful design, but as its first serious loading.</p><h2 id="related-on-this-site">Related on this site</h2><ul><li><strong><a href="/posts/from-clickup-to-vaadin-part-2-the-build-and-the-balance/">From ClickUp to Vaadin — Part 2: The Build and the Balance</a></strong> — the second part of this series: seventy-two commits, browserless view testing, mutation testing as a check on tests written by the assistant, and an honest reckoning of a calculation that has tipped over.</li><li><strong><a href="/posts/when-security-boundaries-become-obstacles/">When Security Boundaries Become Obstacles</a></strong> — the same principle at a different scale: an optimising system given a measurable objective will find routes its builders did not foresee. Applies to importers as much as to evaluation models.</li></ul>
]]></content:encoded><category>Java</category><category>Vaadin</category><media:content url="https://svenruppert.com/images/2026/08/from-clickup-to-vaadin-part-1-hero.jpg" medium="image"/><media:thumbnail url="https://svenruppert.com/images/2026/08/from-clickup-to-vaadin-part-1-hero.jpg"/><enclosure url="https://svenruppert.com/images/2026/08/from-clickup-to-vaadin-part-1-hero.jpg" type="image/jpeg" length="0"/></item></channel></rss>