<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en_US"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://nickstalter.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://nickstalter.com/" rel="alternate" type="text/html" hreflang="en_US" /><updated>2026-09-07T21:29:59-04:00</updated><id>https://nickstalter.com/feed.xml</id><title type="html">Nick Stalter</title><subtitle>I build small apps and write about how they get made.</subtitle><author><name>Nick Stalter</name><email>nickstalter@gmail.com</email></author><entry><title type="html">When Sync Isn’t the Bug: a Day-Card Write Race</title><link href="https://nickstalter.com/when-sync-isnt-the-bug-a-day-card-write-race" rel="alternate" type="text/html" title="When Sync Isn’t the Bug: a Day-Card Write Race" /><published>2026-09-07T21:00:00-04:00</published><updated>2026-09-07T21:00:00-04:00</updated><id>https://nickstalter.com/when-sync-isnt-the-bug-a-day-card-write-race</id><content type="html" xml:base="https://nickstalter.com/when-sync-isnt-the-bug-a-day-card-write-race"><![CDATA[<p>I filed a bug that looked like a sync problem.</p>

<p>On <a href="https://migrainular.com">Migrainular</a>’s Today screen, you can type sleep hours on the day card, then tap the water control. Sometimes the sleep field snapped back to empty. My first note in the issue was basically: <em>server sync is too eager — maybe add a Save button like diary entries.</em></p>

<p>That diagnosis was half right about the <em>feeling</em>, and wrong about the <em>mechanism</em>.</p>

<h2 id="the-setup">The setup</h2>

<p>Migrainular is local-first. Days and diary entries live in IndexedDB (Dexie). Edits mark a row <code class="language-plaintext highlighter-rouge">pending</code>, then a background sync pushes and pulls against the server using last-write-wins on <code class="language-plaintext highlighter-rouge">client_updated_at</code>.</p>

<p>The day card is meant to be fast when you are not feeling well: bump water, chip caffeine, toggle a preventive. Text fields like sleep and food auto-save on change. Diary attacks are different — you edit a form and hit Save. That difference matters.</p>

<p>We already had a guard so background sync would not overwrite an input while you were still focused in it. That helped with “typing while weather or sync refreshes.” It did not help with this bug.</p>

<h2 id="the-repro-that-lied">The repro that lied</h2>

<ol>
  <li>Type a sleep value.</li>
  <li>Tap + water.</li>
  <li>Sleep resets.</li>
</ol>

<p>From the outside: you edited, something synced, the UI reverted. Classic “eager sync” story.</p>

<p>What actually happens on that tap:</p>

<ol>
  <li>Focus leaves sleep → <code class="language-plaintext highlighter-rouge">change</code> fires → <code class="language-plaintext highlighter-rouge">patchDay({ sleep_hours })</code>.</li>
  <li>Click fires → <code class="language-plaintext highlighter-rouge">patchDay({ water_intake })</code>.</li>
  <li>Both paths do a read-merge-write on the same day row.</li>
  <li>The UI hydrates inputs from whatever lands in IndexedDB after the write.</li>
</ol>

<p>Those two <code class="language-plaintext highlighter-rouge">patchDay</code> calls race.</p>

<h2 id="the-real-bug">The real bug</h2>

<p><code class="language-plaintext highlighter-rouge">saveDay</code> looked innocent:</p>

<ol>
  <li>Load the current day from IndexedDB.</li>
  <li>Merge in a partial patch (<code class="language-plaintext highlighter-rouge">sleep_hours</code> <em>or</em> <code class="language-plaintext highlighter-rouge">water_intake</code>).</li>
  <li>Write the whole row back with a new <code class="language-plaintext highlighter-rouge">client_updated_at</code>.</li>
</ol>

<p>If sleep and water both start from a snapshot where sleep is still <code class="language-plaintext highlighter-rouge">null</code>:</p>

<ul>
  <li>Sleep write wins first → <code class="language-plaintext highlighter-rouge">{ sleep: 8, water: 0 }</code></li>
  <li>Water write finishes second from the stale snapshot → <code class="language-plaintext highlighter-rouge">{ sleep: null, water: 8 }</code></li>
</ul>

<p>Sleep is gone. Sync then helpfully ships that loser to the server, and the hydrate effect paints the empty field, because by then you are no longer “typing” — you tapped a button. The typing guard correctly stood down. The data underneath was already wrong.</p>

<p>So yes, sync made the bad state <em>visible and durable</em>. It did not invent the bad state. The race lived on one device, in one database, between two partial writes.</p>

<h2 id="the-fix">The fix</h2>

<p>I did not turn the day card into a Save button. Diary entries earn an explicit Save. Water taps should stay one-handed and cheap. The product constraint was: keep the fast path, stop losing drafts.</p>

<p>Two boring changes:</p>

<ol>
  <li><strong>Serialize <code class="language-plaintext highlighter-rouge">saveDay</code> per day.</strong> Concurrent callers queue. Each write reads the latest row after the previous write finishes, so partial patches compose instead of clobbering.</li>
  <li><strong>Flush draft inputs before chip and button patches.</strong> Water, caffeine chips, preventives, and cycle toggles commit whatever is sitting in the sleep/food/etc. fields first, then apply their own update from that fresh day. Increments use a callback on the post-flush record so “+coffee” does not compute against a stale <code class="language-plaintext highlighter-rouge">day</code> while the input still shows a draft.</li>
</ol>

<p>Sync still runs after saves. Hydrate still refreshes the form from the day record. Those are fine once the local write path stops racing.</p>

<h2 id="what-id-look-for-next-time">What I’d look for next time</h2>

<p>If a form field “resets after sync,” ask:</p>

<ul>
  <li>Did two writes hit the same document with partial patches?</li>
  <li>Does blur plus click fire two async saves in the same gesture?</li>
  <li>Does the UI rehydrate from storage as soon as focus leaves the field?</li>
  <li>Is the “sync” step mostly amplifying a local loser?</li>
</ul>

<p>Local-first apps blur the line between UI state, disk state, and server state. That blur is a feature until a race makes all three agree on the wrong answer.</p>

<p>Sync was not the bug. It was the messenger.</p>

<p><strong><a href="https://migrainular.com">Try Migrainular at migrainular.com</a></strong> — pain, save, done.</p>]]></content><author><name>Nick Stalter</name></author><category term="engineering" /><category term="typescript" /><category term="svelte" /><category term="migrainular" /><category term="svelte" /><category term="pwa" /><category term="local-first" /><category term="indexeddb" /><category term="sync" /><summary type="html"><![CDATA[Sleep hours reset after tapping water on Migrainular's day card. It felt like eager sync. It was two concurrent partial writes to the same IndexedDB row.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nickstalter.com/img/migrainular-today.png" /><media:content medium="image" url="https://nickstalter.com/img/migrainular-today.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Browsing the Dial Without Killing the Hour</title><link href="https://nickstalter.com/airshift-browsing-without-killing-the-hour" rel="alternate" type="text/html" title="Browsing the Dial Without Killing the Hour" /><published>2026-09-05T21:00:00-04:00</published><updated>2026-09-05T21:00:00-04:00</updated><id>https://nickstalter.com/airshift-browsing-without-killing-the-hour</id><content type="html" xml:base="https://nickstalter.com/airshift-browsing-without-killing-the-hour"><![CDATA[<p><a href="https://airshift.fly.dev">Airshift</a> is personal web radio in the tab. The hour is a queue, a transport, and a DJ that already started talking. I wrote about <a href="/airshift-technical-architecture">the architecture</a> and <a href="/airshift-keeping-the-board-live">keeping the board live</a>. This is a smaller seam that bit harder than it looked: <strong>changing stations while something is On Air</strong>.</p>

<h2 id="the-cheap-click">The cheap click</h2>

<p>A preset button looks like a link. On standby it <em>is</em> a link — pick Jazz, the board becomes Jazz. While live, the same click used to mean “tear down the hour and start another.” Queue gone. Generation bumped. Station ID again. OC ReMix into Rock felt like a bug even when it was intentional.</p>

<p>The product rule I wanted: <strong>browse is free; commit costs the hour.</strong> Listeners should be able to peek at Classical’s crates, flip to Hip-Hop’s dial chrome, and stay on Jazz until they mean it.</p>

<h2 id="two-stations-in-one-board">Two stations in one board</h2>

<p>While On Air there are two names for “the station”:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">selected</code></strong> — what the transport is actually playing</li>
  <li><strong><code class="language-plaintext highlighter-rouge">preview</code></strong> — what the dial is focused on</li>
</ul>

<p>The UI reads a single dial:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nf">dialStation</span><span class="p">(</span><span class="nx">preview</span><span class="p">,</span> <span class="nx">selected</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">preview</span> <span class="o">??</span> <span class="nx">selected</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Freq, name, theme, crates, and the share query follow <code class="language-plaintext highlighter-rouge">dial()</code>. The player, queue, and tab title stay on <code class="language-plaintext highlighter-rouge">selected</code>. That split is the whole design.</p>

<p>“Something is pending” is not only “preview ≠ selected.” Crate, order, library, and channel can drift from an <strong><code class="language-plaintext highlighter-rouge">AirSnapshot</code></strong> captured when the hour started. Same station, different crate — still Tune, still don’t kill audio yet.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nf">browsePending</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">input</span><span class="p">.</span><span class="nx">live</span><span class="p">)</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">previewId</span> <span class="o">&amp;&amp;</span> <span class="nx">input</span><span class="p">.</span><span class="nx">previewId</span> <span class="o">!==</span> <span class="nx">input</span><span class="p">.</span><span class="nx">selectedId</span><span class="p">)</span> <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
  <span class="c1">// …crate / order / library / channel vs air snapshot</span>
<span class="p">}</span>
</code></pre></div></div>

<p>On the live path, <code class="language-plaintext highlighter-rouge">selectStation</code> sets <code class="language-plaintext highlighter-rouge">preview</code>, re-renders the board, updates the URL, and returns. It never calls <code class="language-plaintext highlighter-rouge">stop()</code>.</p>

<h2 id="discoverability-is-half-the-bug">Discoverability is half the bug</h2>

<p>Once browse was free, the next failure mode was quieter: people did not know how to finish the switch. The big <strong>On Air</strong> button hides while live (you are already on air). Confirm lived on a small lamp that only said <strong>Tune</strong> <em>after</em> you had already found the dial.</p>

<p>Two fixes:</p>

<ol>
  <li><strong>Stations</strong> next to the lamp while live — opens the tuner fold and scrolls it into view. The fold label becomes “Stations” instead of “Presets.”</li>
  <li>When browse is pending, <strong><code class="language-plaintext highlighter-rouge">#go</code> comes back</strong> as Tune. Same control you used to start the hour.</li>
</ol>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.now.live</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.tune-pending</span><span class="o">)</span> <span class="nf">#go</span> <span class="p">{</span>
  <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>State that only exists in a lamp is state nobody owns. Put the commit on the control people already trust.</p>

<h2 id="gotchas-that-came-with-the-split">Gotchas that came with the split</h2>

<p><strong>Share URL follows the dial.</strong> While you are previewing Classical, <code class="language-plaintext highlighter-rouge">?s=</code> is Classical even though Jazz is still in the speakers. Track slug stays off the URL until you are not pending, so you do not pin Jazz’s song onto Classical’s link.</p>

<p><strong>Cluster stations must not load into the airing queue from a preview.</strong> Calling <code class="language-plaintext highlighter-rouge">loadCluster()</code> from the dial focus while Jazz is live would mutate <code class="language-plaintext highlighter-rouge">queue</code> / <code class="language-plaintext highlighter-rouge">index</code> for the wrong mount. Preview paints channel lists; it does not become the bed.</p>

<p><strong>Tab title stays honest to the air.</strong> The board can show Classical. <code class="language-plaintext highlighter-rouge">document.title</code> still names what you hear. That asymmetry is correct and a little weird until you expect it.</p>

<h2 id="what-it-feels-like">What it feels like</h2>

<p>On Air. Tap Stations. Wander. The hour keeps going. When the dial is where you want it, hit Tune — <em>then</em> the generation flips and the new station ID runs.</p>

<p>Two variables. One button that means commit. A lot less “why did my song die when I was only looking.”</p>

<p><strong><a href="https://airshift.fly.dev">Try it on airshift.fly.dev</a></strong> — go On Air, open Stations, pick something else, then Tune when you mean it.</p>]]></content><author><name>Nick Stalter</name></author><category term="engineering" /><category term="typescript" /><category term="airshift" /><category term="typescript" /><category term="spa" /><category term="ux" /><category term="state" /><summary type="html"><![CDATA[On a live radio SPA, a preset click is not navigation. Preview the dial, keep the queue, and only restart the hour when the listener hits Tune.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nickstalter.com/img/airshift-home.png" /><media:content medium="image" url="https://nickstalter.com/img/airshift-home.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Keeping Airshift’s Board Live</title><link href="https://nickstalter.com/airshift-keeping-the-board-live" rel="alternate" type="text/html" title="Keeping Airshift’s Board Live" /><published>2026-08-26T22:00:00-04:00</published><updated>2026-08-26T22:00:00-04:00</updated><id>https://nickstalter.com/airshift-keeping-the-board-live</id><content type="html" xml:base="https://nickstalter.com/airshift-keeping-the-board-live"><![CDATA[<p><a href="https://airshift.fly.dev">Airshift</a> is personal web radio in the tab. The crate is the hour. The product is the space in between the music — the ID, the backsell, the duck under the bed. I wrote about <a href="/introducing-airshift">the product</a> and <a href="/airshift-technical-architecture">how it is built</a>. This is the week after: the board was hitching when the DJ got ready, and that is the opposite of a station.</p>

<p>The fake visualizer is supposed to bounce the whole time. It looks like Winamp. Pausing it to “save” a YouTube hour was a non-starter. The work was: keep the bars moving, keep the buttons snappy, and still have a break ready when the song ends.</p>

<h2 id="the-freeze">The freeze</h2>

<p>Kokoro runs in the page, ONNX, same origin as the iframe. CPU inference on the UI thread is a freeze. The board stops. FPS drops. You click Skip and nothing happens until the model is done talking to itself.</p>

<p>WebGPU already leaves JavaScript. WASM does not, unless you ask ONNX Runtime to <strong>proxy</strong> the session into a worker. That is now the CPU path. Generate still takes a while on a laptop; the page no longer goes dead while it does.</p>

<p>On Air also <strong>warms</strong> the model instead of waiting for the first break to download it. Slow generates (about 400ms, or every generate with <code class="language-plaintext highlighter-rouge">?debug=1</code>) show up in the booth log so I can see which device is actually winning.</p>

<p>FPS lives in the mast all the time. The meter pauses when the tab is hidden. That was the cheap way to stop arguing about whether the board felt stuck.</p>

<h2 id="breaks-that-do-not-wait-on-the-internet">Breaks that do not wait on the internet</h2>

<p>A full break used to wait on Genius, Wikipedia, Ticketmaster, weather, news. If a helper was slow, the song ended and the DJ had nothing.</p>

<p>The skeleton is local: backsell, station ID, frontsell. Those lines synthesize as soon as the current song starts. Facts, headlines, weather, and tour dates <strong>clip in only if they are already ready</strong>. If they are not, the break still happens. <code class="language-plaintext highlighter-rouge">joinBreak</code> is the whole policy: head, optional extra, tail.</p>

<p>Talk is the same idea. Punch <strong>T</strong> and you get the booth, not a spinner behind a fetch.</p>

<h2 id="first-paint-and-the-next-song">First paint and the next song</h2>

<p><code class="language-plaintext highlighter-rouge">stations.json</code> is not small. It used to load with the main module, so the first paint waited on every crate. Catalog is a dynamic import now. Presets hydrate when the JSON lands. The JSON files are still the source of truth for <code class="language-plaintext highlighter-rouge">lookup-ids</code>; they are just off the critical path.</p>

<p>YouTube was the bigger hitch. <code class="language-plaintext highlighter-rouge">loadVideoById</code> on the live iframe means the next track starts cold. There is a second mount, <code class="language-plaintext highlighter-rouge">#player-next</code>, sitting at <code class="language-plaintext highlighter-rouge">opacity: 0</code> (not <code class="language-plaintext highlighter-rouge">display: none</code>, or YouTube will not buffer). When the current song starts, we <code class="language-plaintext highlighter-rouge">cueVideoById</code> the next id in the queue. When that id is due, we swap which iframe is live and hit play. Same-id play still resumes, so talk-with-bed does not jump to the standby video.</p>

<h2 id="gpu-when-the-browser-has-it">GPU when the browser has it</h2>

<p>The architecture post said CPU was the default. That was the safe story. It was also leaving speed on the table.</p>

<p>If nothing is saved and <code class="language-plaintext highlighter-rouge">navigator.gpu</code> exists, Kokoro starts on <strong>WebGPU</strong>. A stored CPU choice still wins. We do not write localStorage until you touch the select, so a machine that cannot keep WebGPU honest is one click from WASM, and we will not overwrite that.</p>

<h2 id="what-it-feels-like">What it feels like</h2>

<p>The bars keep going. The next YouTube hour is often already cued. The DJ still talks; it just does not freeze the board to get there. <a href="https://airshift.fly.dev">91.3 Indie</a> showed up on the dial the same night, which is unrelated and also the point of a station you leave on: new crate, same clock.</p>

<p><strong><a href="https://airshift.fly.dev">Try Airshift at airshift.fly.dev</a></strong> — pick a frequency, hit On Air, watch the mast.</p>]]></content><author><name>Nick Stalter</name></author><category term="engineering" /><category term="typescript" /><category term="airshift" /><category term="typescript" /><category term="performance" /><category term="kokoro" /><category term="youtube" /><summary type="html"><![CDATA[Kokoro was freezing the board while it wrote the next break. The visualizer stays on. The DJ preps in a worker, the catalog loads late, and the next YouTube id is already cued.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nickstalter.com/img/airshift-home.png" /><media:content medium="image" url="https://nickstalter.com/img/airshift-home.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Switching Migrainular to Open-Meteo</title><link href="https://nickstalter.com/switching-migrainular-to-open-meteo" rel="alternate" type="text/html" title="Switching Migrainular to Open-Meteo" /><published>2026-08-26T21:50:00-04:00</published><updated>2026-08-26T21:50:00-04:00</updated><id>https://nickstalter.com/switching-migrainular-to-open-meteo</id><content type="html" xml:base="https://nickstalter.com/switching-migrainular-to-open-meteo"><![CDATA[<p><a href="https://migrainular.com">Migrainular</a> is a private migraine diary. I wrote about <a href="/introducing-migrainular">the product</a> when it launched. This is a smaller note: we changed how weather gets onto an attack.</p>

<p>Weather is optional. Pain still saves with no network. When you <em>are</em> online, Today can show conditions and stamp <strong>barometric pressure</strong> (plus humidity and temperature) onto the entry. Pressure is the useful bit. A falling barometer is something a lot of us already feel; the app counts it, it does not forecast.</p>

<p>At launch that snapshot came from OpenWeather, proxied through Phoenix so the API key never sat in the PWA. That worked. It also meant a Fly secret, a quota, and a cache whose whole job was “do not burn the free tier.”</p>

<p>Migrainular will not charge people. OpenWeather’s key was leftover vendor machinery for a free app.</p>

<h2 id="why-open-meteo">Why Open-Meteo</h2>

<p><a href="https://open-meteo.com/">Open-Meteo</a> is a free weather API for non-commercial use. No key. You ask for coordinates and the variables you actually want. For us that is current temperature, humidity, weather code, sea-level pressure, surface pressure, and a few hourly pressure points so we can show the change over the next three hours.</p>

<p>Their free tier is 10,000 calls a day, 5,000 an hour, 600 a minute. They ask for <a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a> attribution. That matches a diary with no ads and no subscriptions.</p>

<p>The PWA still does not talk to Open-Meteo. Phoenix is the proxy. One Fly IP, one cache, diary contents never ride along. Privacy stays the same shape as launch: location in, a small snapshot out.</p>

<p>City and ZIP go through their geocoding API first. Device GPS skips that and sends lat/lon. The JSON we hand the client did not change, so Today, the stamp on an entry, and the barometer overlay on Trends kept working.</p>

<h2 id="not-burning-the-free-tier">Not burning the free tier</h2>

<p>OpenWeather trained us to cache. We kept that, and tightened the GPS path.</p>

<p>Weather models are kilometer-scale. A phone is not. If you cache on the raw float, every visit to Today is a new key and a real upstream call. We round to two decimal degrees (~1 km) before the cache key <em>and</em> the request. People in the same neighborhood share a slot. City and ZIP already did.</p>

<p>The rest is boring on purpose:</p>

<ul>
  <li>About 20 minutes for conditions, a week for geocoding</li>
  <li>Only the fields we stamp, not a week of unused forecast</li>
  <li>A <code class="language-plaintext highlighter-rouge">User-Agent</code> that names the app</li>
  <li>No retries on a 500—the client can save the attack without weather</li>
</ul>

<p>Dev stubs the upstream so local work does not hit them at all.</p>

<h2 id="what-you-see-in-the-app">What you see in the app</h2>

<p>If weather is still fetching, Today says it is checking. If it fails, it says it failed. It only asks you to set a city, ZIP, or device location when you actually have not. Privacy and Profile credit Open-Meteo.</p>

<p>That is the whole change: same 20-second log, same pressure stamp, no vendor key. <a href="https://open-meteo.com/">Open-Meteo</a> does the weather. Migrainular stays a diary.</p>

<p><strong><a href="https://migrainular.com">Try Migrainular at migrainular.com</a></strong> — pain, save, done.</p>]]></content><author><name>Nick Stalter</name></author><category term="engineering" /><category term="elixir" /><category term="phoenix" /><category term="migrainular" /><category term="phoenix" /><category term="elixir" /><category term="weather" /><category term="open-meteo" /><summary type="html"><![CDATA[Migrainular stamps barometric pressure onto attacks. We dropped the OpenWeather API key and proxy Open-Meteo instead: no vendor account, same snapshot, and a cache that does not punish GPS noise.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nickstalter.com/img/migrainular-today.png" /><media:content medium="image" url="https://nickstalter.com/img/migrainular-today.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Feature Flags Without a SaaS: How OnRotation Gates Features</title><link href="https://nickstalter.com/onrotation-feature-flags" rel="alternate" type="text/html" title="Feature Flags Without a SaaS: How OnRotation Gates Features" /><published>2026-08-25T20:35:00-04:00</published><updated>2026-08-25T20:35:00-04:00</updated><id>https://nickstalter.com/onrotation-feature-flags</id><content type="html" xml:base="https://nickstalter.com/onrotation-feature-flags"><![CDATA[<p><a href="https://onrotation.app">OnRotation</a> is a household meal planner. When we ship something early—AI meal ideas, a new import path—we want a handful of people to see it, not everyone with an account.</p>

<p>We do not run a flags SaaS. Auth is already magic-link email, so “who is this?” is on the request. The system is a table, an allowlist, and one check. That is not a flex about avoiding dependencies for its own sake. It is what Elixir and Phoenix are good at: the domain lives in a context, the UI is a LiveView, persistence is Ecto, and the same module serves the API. You can ship a working feature-flag system in an afternoon and it will feel like the rest of the app.</p>

<p>I wrote about <a href="/onrotation-technical-architecture">the rest of the architecture</a> separately; this is the smaller piece: turning a feature on for a few people without a deploy.</p>

<h2 id="why-not-the-usual-tools">Why not the usual tools</h2>

<p>The Elixir default is <a href="https://github.com/tompave/fun_with_flags">FunWithFlags</a>: Ecto or Redis, ETS cache, groups, percentages, PubSub. <a href="https://launchdarkly.com">LaunchDarkly</a> (and Unleash, Flagsmith, and the rest) add targeting rules, client SDKs, and an audit trail. All of that is real product. We did not need it.</p>

<p>We have a small user base and a known list of testers. Percentages and anonymous actors are unused. A vendor SDK on web plus Android plus iOS is another integration to keep honest. FunWithFlags would have been the reasonable library choice; we still would have built Admin UI and wired checks to <code class="language-plaintext highlighter-rouge">current_scope</code>. For that amount of surface, a row and a function stayed smaller than adopting someone else’s model—and that is the interesting part. In a lot of stacks, “just build it” is how you get an unmaintainable snowflake. In Elixir, a context plus a schema plus a LiveView <em>is</em> the maintainable shape. The custom version looks like every other OnRotation feature.</p>

<h2 id="admin-vs-flags">Admin vs flags</h2>

<p><strong>Admin</strong> is not a feature flag. A config list of emails is who can open <code class="language-plaintext highlighter-rouge">/admin</code> and hit the admin API. That list changes rarely and should survive a bad database row.</p>

<p><strong>Feature flags</strong> are for product. They live in Postgres, get edited from Admin (web, Android, or iOS), and answer: <em>does this signed-in user get this experimental UI?</em> Mixing the two would mean either redeploying to add a beta tester, or handing blog-admin to someone who only needed meal ideas.</p>

<h2 id="the-model">The model</h2>

<p>A flag has a unique <strong>name</strong> (<code class="language-plaintext highlighter-rouge">ai_meal_ideas</code>), an optional <strong>description</strong>, and <strong>allowed emails</strong>. There is no on/off switch, no percentage, no household targeting. Empty emails, or a name that is not in the table, means off. If we forget to create the flag, nobody sees the feature.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">schema</span> <span class="s2">"feature_flags"</span> <span class="k">do</span>
  <span class="n">field</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">:string</span>
  <span class="n">field</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">:string</span>
  <span class="n">field</span> <span class="ss">:allowed_emails</span><span class="p">,</span> <span class="ss">:string</span>
  <span class="n">timestamps</span><span class="p">(</span><span class="ss">type:</span> <span class="ss">:utc_datetime</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Emails are a comma-separated string. A join table would be cleaner to query; a textarea we edit a few times a month does not need that.</p>

<p>The check is one function: given the caller’s <strong>scope</strong> (user + household) and a flag name, is this user’s email on the list? Comparison is case-insensitive. Missing user or missing flag is false. We look the row up when we need it. Caching can wait until a check is actually hot.</p>

<h2 id="where-it-gates">Where it gates</h2>

<p>Call sites hide the UI <strong>and</strong> refuse the action. LiveView events are still reachable if you know the name, so the same predicate runs before we spend money or mutate data.</p>

<p>AI meal ideas is the live example. The feature also needs an AI backend configured (Gemini, Ollama, or OpenAI). Env says the machine can do it; the flag says which people may. Both have to be true:</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">defp</span> <span class="n">meal_ideas_enabled?</span><span class="p">(</span><span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">scope</span> <span class="o">=</span> <span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">current_scope</span>
  <span class="no">MealIdeas</span><span class="o">.</span><span class="n">available?</span><span class="p">()</span> <span class="ow">and</span> <span class="no">FeatureFlags</span><span class="o">.</span><span class="n">enabled_for_scope?</span><span class="p">(</span><span class="n">scope</span><span class="p">,</span> <span class="s2">"ai_meal_ideas"</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>That is the whole gate: a context function and a boolean. The LiveView, the event handlers, and the API can all ask the same question.</p>

<p>Native apps do not evaluate flags for end users yet. They <strong>do</strong> manage them: the admin API is the same context, not a second source of truth.</p>

<h2 id="what-we-skipped">What we skipped</h2>

<p>We do not do percentage rollouts—we know the emails. We do not evaluate flags on the client and trust the button; the server decides. Names are strings, not an enum, so shipping a feature to everyone is delete the row and grep the checks. Experiments are person-scoped even though meals are household-scoped. If we ever want a household in a beta, that is a new field, not more emails.</p>

<p>Adding a flag is: gate the code, create the row in Admin, paste emails. When it is done, delete the flag and the checks.</p>

<p>Textarea and Postgres is the right amount of machinery. The point is not that flags are easy. It is that Elixir lets a small team build the version that actually fits the product, keep it in the same codebase as the meals and the plan, and stop when it gets the job done.</p>

<p><strong><a href="https://onrotation.app">Try OnRotation at onrotation.app</a></strong> — start with the Starter Pack and plan the week in minutes.</p>]]></content><author><name>Nick Stalter</name></author><category term="engineering" /><category term="elixir" /><category term="phoenix" /><category term="phoenix" /><category term="liveview" /><category term="elixir" /><category term="feature-flags" /><category term="onrotation" /><summary type="html"><![CDATA[OnRotation's feature flags are a Postgres row, an email allowlist, and one check on the signed-in user. Elixir makes that enough: no LaunchDarkly, no extra library—just something that gets the job done.]]></summary></entry><entry><title type="html">Introducing Airshift</title><link href="https://nickstalter.com/introducing-airshift" rel="alternate" type="text/html" title="Introducing Airshift" /><published>2026-08-25T08:00:00-04:00</published><updated>2026-08-25T08:00:00-04:00</updated><id>https://nickstalter.com/introducing-airshift</id><content type="html" xml:base="https://nickstalter.com/introducing-airshift"><![CDATA[<p>I wanted a radio I could leave on. Not a playlist I have to skip through. Not an algorithm that forgets the format the second I blink. Something that feels like a station: a frequency, a crate, a voice that comes in over the bed and tells you what just played.</p>

<p>Playlists are the songs. A station is the space in between the music. That is the part <a href="https://airshift.fly.dev">Airshift</a> is for.</p>

<figure>
  <img class="shot-wide" src="/img/airshift-home.png" alt="Airshift board on standby: 101.7 Rock selected, presets down the left, On Air in the middle, empty monitor on the right" />
  <figcaption>The board. Pick a frequency. Hit On Air. Walk away.</figcaption>
</figure>

<h2 id="what-it-is">What it is</h2>

<p>Airshift is a personal web radio that lives in the browser. You pick a format, hit <strong>On Air</strong>, and it keeps going. The crate is how you pick an hour. The work is the space in between: station IDs, backsells, a fact, the bed ducked under the voice. Songs on most hours come from hardcoded YouTube lists in a visible iframe. Breaks duck the track instead of pausing it. The DJ reads canned liners, a Wikipedia or Genius fact, optional weather, and optional headlines.</p>

<p>There is no account. There is no YouTube Music login. Add it to the home screen if you want; Bluetooth lock-screen controls use Media Session. This is not Android Auto—the car sees whatever Chrome is playing.</p>

<h2 id="the-tuner">The tuner</h2>

<p>Presets are frequencies, not playlists:</p>

<ul>
  <li><strong>88.3 Jazz</strong> through <strong>103.5 Pop Hits</strong>, with <strong>101.7 Rock</strong> as the default</li>
  <li><strong>90.3 The Wire</strong> — live mounts (KEXP, WWOZ, Jazz24, KUTX, Radio Paradise, Nightwave Plaza, KCRW Eclectic24, The Current, dublab, KXLU, WKCR)</li>
  <li><strong>89.7 OC ReMix</strong> — Rainwave’s video-game remix channels</li>
  <li><strong>104.7 Yours</strong> — paste video links or JSON onto the crate. Playlist URLs are not expanded on the public origin (that would be an open proxy).</li>
</ul>

<p>Rock has <strong>subcrates</strong> (Classic, Grunge, Britpop, and so on) so you can park on one flavor the way The Wire parks on a city. Hits are the default library; Settings can swap the hour to <strong>deep cuts</strong>, or a mix. Shuffle is on unless you ask for in-order. The next YouTube hour avoids the last 12 songs on that station.</p>

<p>Keys: <code class="language-plaintext highlighter-rouge">1</code>–<code class="language-plaintext highlighter-rouge">9</code> presets, space pause, <code class="language-plaintext highlighter-rouge">S</code> skip, <code class="language-plaintext highlighter-rouge">P</code> prev, <code class="language-plaintext highlighter-rouge">T</code> talk, <code class="language-plaintext highlighter-rouge">.</code> end song.</p>

<h2 id="the-booth">The booth</h2>

<p>The DJ is not a large language model making up a morning show. It is a clock: station IDs, frontsells, backsells, skip lines, time checks. Facts come from Genius when the sidecar has a token, Wikipedia when it does not. Weather is Open-Meteo. News is NPR, local, BBC World, or NPR Music.</p>

<p>Voice is either the browser’s speech engine or <strong>Kokoro</strong> running in the tab (ONNX, CPU by default, GPU if your machine likes it). Station intros are packed as WAV so On Air does not wait on the model. Uncached lines generate while a song plays, then they are ready when the break hits. If Kokoro fails, the hour keeps going on the browser voice.</p>

<p>Sleep timer fades the bed into Standby. Feedback in Settings goes to the booth.</p>

<h2 id="where-it-runs">Where it runs</h2>

<p><strong><a href="https://airshift.fly.dev">airshift.fly.dev</a></strong> is the origin: phone, PWA, car. Playback is in the tab; Fly only has to serve the page and a few helpers, so scale-to-zero is fine.</p>

<p>A home box is still unsolved. I want the board on the LAN—always on, playlist URLs, maybe a custom news feed—without turning Fly into an open proxy, and without a self-signed cert that YouTube will not embed against. HTTPS at home, HTTP on the LAN, or something in between: that is next, not this launch.</p>

<p>I spend most of my product time in Phoenix LiveView. Airshift is not that. The hour has to keep playing while you lock the phone, and the DJ has to speak in the same tab as the iframe. The stack for that is in a <a href="/airshift-technical-architecture">follow-up</a>.</p>

<h2 id="try-it">Try it</h2>

<p>Airshift is live at <a href="https://airshift.fly.dev">airshift.fly.dev</a>. Pick 101.7, hit On Air, and leave the monitor visible on YouTube hours—some uploads refuse to embed and get skipped.</p>

<p>If a mount is dead or a <code class="language-plaintext highlighter-rouge">videoId</code> is wrong, send it through the feedback line. I listen to this thing too.</p>]]></content><author><name>Nick Stalter</name></author><category term="product" /><category term="launch" /><category term="airshift" /><category term="radio" /><category term="pwa" /><category term="typescript" /><summary type="html"><![CDATA[Personal web radio you leave on. The crate is the hour; the station is the space in between the music.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nickstalter.com/img/airshift-home.png" /><media:content medium="image" url="https://nickstalter.com/img/airshift-home.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Airshift: Technical Architecture of a Personal Web Radio</title><link href="https://nickstalter.com/airshift-technical-architecture" rel="alternate" type="text/html" title="Airshift: Technical Architecture of a Personal Web Radio" /><published>2026-08-25T07:30:00-04:00</published><updated>2026-08-25T07:30:00-04:00</updated><id>https://nickstalter.com/airshift-technical-architecture</id><content type="html" xml:base="https://nickstalter.com/airshift-technical-architecture"><![CDATA[<p><a href="https://airshift.fly.dev">Airshift</a> is personal web radio in the browser. I wrote about <a href="/introducing-airshift">the product</a> separately. This post is the architecture: what runs where, why it is not Phoenix, and the seams that keep a YouTube hour, a live Icecast mount, and a talking DJ in the same tab.</p>

<h2 id="why-not-liveview">Why not LiveView</h2>

<p><a href="https://onrotation.app">OnRotation</a> is Phoenix LiveView. <a href="https://migrainular.com">Migrainular</a> is a Svelte PWA talking to a Phoenix API, because a migraine log has to save with no network.</p>

<p>Airshift is a third shape. Playback is client-owned. YouTube’s IFrame API, HTML5 audio for live mounts, Web Speech or Kokoro, Media Session, and a service worker all want to live next to the board. A LiveView socket cannot own that hour. Fly can sleep; the tab cannot.</p>

<p>So the app is a <strong>Vite + TypeScript</strong> SPA with <strong>vanilla DOM</strong>—no React, no Svelte. One <code class="language-plaintext highlighter-rouge">main.ts</code> drives the board. CSS is a single file with station themes as variables (Studio, Midnight, After Hours, and the rest). Tests are <code class="language-plaintext highlighter-rouge">node:test</code> with <code class="language-plaintext highlighter-rouge">--experimental-strip-types</code>.</p>

<h2 id="tech-stack">Tech stack</h2>

<ul>
  <li><strong>TypeScript ~7</strong> and <strong>Vite 8</strong></li>
  <li><strong>vite-plugin-pwa</strong> (Workbox, Add to Home Screen)</li>
  <li><strong>YouTube IFrame API</strong> for format hours</li>
  <li><strong>HTML5 <code class="language-plaintext highlighter-rouge">&lt;audio&gt;</code></strong> for The Wire and OC ReMix</li>
  <li><strong>kokoro-js</strong> (Kokoro-82M ONNX in the tab) or the browser voice</li>
  <li><strong>Node <code class="language-plaintext highlighter-rouge">http</code> server</strong> (<code class="language-plaintext highlighter-rouge">scripts/serve.mjs</code>) for <code class="language-plaintext highlighter-rouge">dist/</code> plus a sidecar</li>
  <li><strong>Fly.io</strong> (<code class="language-plaintext highlighter-rouge">iad</code>, shared-cpu-1x, 256 MB, scale-to-zero, force HTTPS)</li>
  <li><strong>Simple Analytics</strong> for pageviews and <code class="language-plaintext highlighter-rouge">station_play</code> (no cookies, no song titles)</li>
  <li><strong>Open-Meteo</strong>, <strong>Wikipedia</strong>, <strong>Genius</strong> (token on the server), <strong>RSS</strong> news</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">youtubei.js</code> is a <strong>dev</strong> dependency for playlist-URL expand and <code class="language-plaintext highlighter-rouge">lookup-ids</code>. It is not on the Fly image.</p>

<h2 id="two-transports">Two transports</h2>

<p><code class="language-plaintext highlighter-rouge">player.ts</code> exposes one <code class="language-plaintext highlighter-rouge">Transport</code>: play, pause, seek, volume, times. Behind it:</p>

<ol>
  <li><strong>YouTube</strong> — load the IFrame API, <code class="language-plaintext highlighter-rouge">loadVideoById</code>, skip on error (uploads that refuse to embed). The studio <strong>monitor</strong> has to stay visible; that is a YouTube constraint, not a UI flourish.</li>
  <li><strong>Audio</strong> — <code class="language-plaintext highlighter-rouge">audio-player.ts</code> wraps a single <code class="language-plaintext highlighter-rouge">&lt;audio&gt;</code> element. Live mounts get mirror URLs, a stall watchdog (~15s without a progress beat), and failover. Breaks <strong>duck</strong> volume instead of calling pause, so Icecast does not drop.</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">playSourceOf</code> picks <code class="language-plaintext highlighter-rouge">youtube</code> vs <code class="language-plaintext highlighter-rouge">audio</code> from the track. The Wire and OC ReMix are <code class="language-plaintext highlighter-rouge">kind: "audio"</code> stations: channel strips, not song crates. You pick KEXP or Rainwave Chill and stay there. Now-playing for Rainwave and Nightwave Plaza is polled over CORS; no auth.</p>

<h2 id="the-clock">The clock</h2>

<p>Stations live in JSON (<code class="language-plaintext highlighter-rouge">stations.json</code>, <code class="language-plaintext highlighter-rouge">deep-cuts.json</code>) plus a couple of code-defined strips (<code class="language-plaintext highlighter-rouge">wire.ts</code>, <code class="language-plaintext highlighter-rouge">ocremix.ts</code>). A track is a title, artist, optional year, and either a <code class="language-plaintext highlighter-rouge">videoId</code> or an <code class="language-plaintext highlighter-rouge">audioUrl</code>.</p>

<p><code class="language-plaintext highlighter-rouge">clock.ts</code> is the crate logic:</p>

<ul>
  <li><strong>Subcrates</strong> on Rock (Classic, Grunge, Britpop, …) so one frequency can still park on a flavor</li>
  <li><strong>Library</strong> — hits, deep cuts, or mix</li>
  <li><strong>Order</strong> — shuffle or in-order</li>
  <li><strong>Don’t-repeat</strong> — next YouTube hour avoids the last 8 / 12 / 16 on that station</li>
  <li>Legacy tuner ids migrated into Rock subcrates via <code class="language-plaintext highlighter-rouge">localStorage</code></li>
</ul>

<p>Yours 104.7 is a station whose crate you paste in (video links or JSON). Expanding a YouTube Music playlist URL needs <code class="language-plaintext highlighter-rouge">youtubei.js</code>. That stays off Fly on purpose.</p>

<h2 id="the-dj">The DJ</h2>

<p>That in-between is the product. Liners are functions, not a prompt: station ID, frontsell, backsell, skip, time check, weather, news, a Genius or wiki fact. Copy is packed into <code class="language-plaintext highlighter-rouge">baked-liners.json</code> as WAV so the first ID does not wait on the model. <code class="language-plaintext highlighter-rouge">npm run bake-liners</code> rebuilds that after copy changes.</p>

<p><strong>Kokoro</strong> runs in the page (<code class="language-plaintext highlighter-rouge">onnx-community/Kokoro-82M-v1.0-ONNX</code>). CPU/WASM is the stable default; WebGPU is faster on some machines and garbage on others. The voice files download from Hugging Face once and cache in the browser. Uncached lines (facts, weather, news) generate <strong>during the song</strong>, then play at the break. If the model fails, Web Speech takes over and the hour continues.</p>

<p>Duck the bed, talk, restore. Mute is a setting. Sleep timer (<code class="language-plaintext highlighter-rouge">sleep.ts</code>) fades gain into Standby.</p>

<h2 id="sidecar-not-a-backend">Sidecar, not a backend</h2>

<p>Production is still a static SPA. <code class="language-plaintext highlighter-rouge">serve.mjs</code> maps files from <code class="language-plaintext highlighter-rouge">dist/</code>, SPA-fallbacks unknown paths, and hands a few routes to <code class="language-plaintext highlighter-rouge">sidecar.mjs</code>:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">GET /airshift/news</code> — allowlisted feeds (NPR / BBC / NPR Music / local place)</li>
  <li><code class="language-plaintext highlighter-rouge">GET /airshift/genius</code> — token from Fly secrets</li>
  <li><code class="language-plaintext highlighter-rouge">POST /airshift/feedback</code> — in-app note to the booth</li>
  <li><code class="language-plaintext highlighter-rouge">POST /airshift/error</code> — client/server errors</li>
  <li><code class="language-plaintext highlighter-rouge">GET /airshift/playlist</code> — <strong>404</strong> on Fly</li>
</ul>

<p>The public VM is not an open proxy. Rate limit is 40 requests / minute / IP. Genius never ships the token to the tab. If news or Genius is down, the hour continues without that line.</p>

<h2 id="pwa-and-the-car">PWA and the car</h2>

<p>Workbox precaches the shell, skips WASM/ONNX blobs (those are huge), and treats <code class="language-plaintext highlighter-rouge">/airshift/*</code> as NetworkOnly. <code class="language-plaintext highlighter-rouge">display: standalone</code>, Apple meta tags, Media Session for lock-screen skip/pause. Chrome can Add to Home Screen from the HTTPS origin; iPhone is Share → Add to Home Screen.</p>

<p>Fly config is boring on purpose: <code class="language-plaintext highlighter-rouge">auto_stop_machines = "stop"</code>, <code class="language-plaintext highlighter-rouge">min_machines_running = 0</code>. The first hit after sleep pays a wake; then you are in the tab and the VM can nap again.</p>

<h2 id="what-stays-out-of-the-tab">What stays out of the tab</h2>

<ul>
  <li>Genius token</li>
  <li>Discord for feedback and crash notes (no crate)</li>
  <li>Playlist-URL expand (not on the public origin)</li>
</ul>

<p>Analytics is Simple Analytics with Do Not Track respected. <code class="language-plaintext highlighter-rouge">sa_event("station_play", { station })</code> is the only custom event. Song titles stay off the wire.</p>

<h2 id="still-open-home">Still open: home</h2>

<p>The board wants to live on the LAN too: always on, paste a playlist URL, maybe a custom RSS feed. The constraints fight each other. Fly cannot grow an open proxy. A self-signed cert on a home box breaks YouTube embeds. Plain HTTP on the LAN works for embeds and dies the moment you want a real PWA, geolocation, or Kokoro on a phone that is not on that Wi‑Fi.</p>

<p>There is Docker and a playlist flag in the repo. I would not call that a product yet. Next is a home origin that is trusted enough for YouTube and private enough for expand—without teaching the Fly VM to fetch arbitrary lists.</p>

<h2 id="summary">Summary</h2>

<p>Airshift is a client radio with a tiny same-origin helper:</p>

<ul>
  <li>Vite TypeScript SPA, vanilla DOM, CSS variables for the board</li>
  <li>YouTube iframe hours and HTML5 live mounts behind one transport</li>
  <li>Crate logic in <code class="language-plaintext highlighter-rouge">clock.ts</code>, liners in <code class="language-plaintext highlighter-rouge">liners.ts</code>, speech in <code class="language-plaintext highlighter-rouge">speech.ts</code></li>
  <li>Kokoro in-tab with baked WAVs and a browser-voice fallback</li>
  <li>Fly for HTTPS / PWA; home still TBD</li>
</ul>

<p>The lesson I keep relearning: put the process where the constraint is. For a meal plan, that is the server. For a diary during an attack, that is IndexedDB. For a station you leave on, that is the tab.</p>

<p><strong><a href="https://airshift.fly.dev">Try Airshift at airshift.fly.dev</a></strong> — pick a frequency and hit On Air.</p>]]></content><author><name>Nick Stalter</name></author><category term="engineering" /><category term="typescript" /><category term="airshift" /><category term="typescript" /><category term="vite" /><category term="pwa" /><category term="kokoro" /><summary type="html"><![CDATA[How Airshift is built: a Vite TypeScript SPA, two playback transports, an in-tab DJ, a tiny Node sidecar, and Fly as a scale-to-zero HTTPS origin.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nickstalter.com/img/airshift-home.png" /><media:content medium="image" url="https://nickstalter.com/img/airshift-home.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Introducing Migrainular</title><link href="https://nickstalter.com/introducing-migrainular" rel="alternate" type="text/html" title="Introducing Migrainular" /><published>2026-08-15T00:00:00-04:00</published><updated>2026-08-15T00:00:00-04:00</updated><id>https://nickstalter.com/introducing-migrainular</id><content type="html" xml:base="https://nickstalter.com/introducing-migrainular"><![CDATA[<p>I have chronic migraines. Day in, day out. Typically, I am not in the mood to open a spreadsheet, or an app with 1000 fields to enter. I want to tap a number, maybe tap a medication, and lie back down.</p>

<p>That is the whole product. I spend 20 seconds or less filling out my diary entry.</p>

<figure>
  <img class="shot-wide" src="/img/migrainular-home.png" alt="Migrainular home: a private diary, 20 seconds or less, three steps if it hurts right now" />
  <figcaption>The home screen. No walkthrough after you sign in.</figcaption>
</figure>

<p><a href="https://migrainular.com">Migrainular</a> is a private migraine diary. It lives on your phone as a Progressive web app(PWA). Days and attacks save on the device first. Sync is optional, when you choose, so you can restore on another device or print something for a clinician. It is a personal tracker—not a diagnosis, not a treatment, not medical advice.</p>

<p>I already owned the name. There was an older Phoenix app under it that stored a handful of fields and was not a diary I would actually use during an attack. I started over.</p>

<h2 id="the-constraint">The constraint</h2>

<p>A useful attack log has to be completable in about <strong>20 seconds</strong>. Pain level is the only required field. Everything else is a chip you can skip.</p>

<p>If you have logged before, <strong>Same as last time</strong> copies the previous attack—pain, location, aura, meds, symptoms—so you can glance and save. Notes, timing, and weather stay with this attack, not the last one.</p>

<figure>
  <img class="shot" src="/img/migrainular-entry.png" alt="Diary entry form: only pain level is required, Same as last time, type and location chips" />
  <figcaption>Pain is the only required field. Everything else is a chip you can skip.</figcaption>
</figure>

<p>The day card (sleep, water, food, caffeine, workout, CPAP AHI) is a different job. That is “when you have more energy.” It is not on the way to Save.</p>

<figure>
  <img class="shot" src="/img/migrainular-today.png" alt="Today screen with Add diary entry, Same as last time, weather, and the day card" />
  <figcaption>Today: log an attack at the top. The day card can wait.</figcaption>
</figure>

<p>I have been ruthless about this. If a new field would make someone in pain think, it does not belong on the form.</p>

<h2 id="what-you-can-log">What you can log</h2>

<p><strong>Attacks:</strong> pain 0–10, type, location, aura, skippable chips for nausea and light/sound/smell sensitivity, when it started, whether it is still going, and medications.</p>

<p><strong>Medications</strong> are a library in Profile, not a pharmacy. You list what you take. Rescue meds can show as one-tap chips on an attack. A monthly preventive can stay on the list without appearing every time you log. Deletes sync across devices.</p>

<p><strong>Days:</strong> sleep, water (glasses or ounces), food, caffeine, workout, and—if you use a machine—<strong>CPAP - AHI</strong> (events per hour), not a vendor 0–100 score.</p>

<p><strong>Weather</strong> is optional. Conditions are proxied through Phoenix so the client never talks to the weather API. Barometric pressure gets stamped onto entries when you are online. Offline, you still save. We started on OpenWeather; we now use <a href="/switching-migrainular-to-open-meteo">Open-Meteo</a>.</p>

<p><strong>Trends</strong> are on-device charts: pain over time, overlays for sleep, caffeine, barometer, and AHI. <strong>Export</strong> is CSV plus a printable provider summary, including your current medication list.</p>

<p>Sign in is a magic link. No password. Logout clears the diary on that device; sign back in and pull from your account. There is a <strong>Clear this device</strong> control if you lent someone a browser.</p>

<h2 id="why-a-pwa-not-liveview">Why a PWA, not LiveView</h2>

<p>I spend a lot of time in Phoenix LiveView—<a href="https://onrotation.app">OnRotation</a> is built that way, and I have written about making LiveView feel <a href="/phoenix-liveview-offline-ish-list-with-localstorage-and-a-follow-up-event">offline-ish</a>. For a meal plan on the couch, a reconnect cache is enough.</p>

<p>For a migraine log, it is not. The save button has to work with no network, in airplane mode, with a flaky cell connection, after the tab has been killed. The client owns the write: IndexedDB first (Dexie), then a boring last-write-wins sync when you are online.</p>

<p>So Migrainular is:</p>

<ul>
  <li><strong>Svelte 5</strong> PWA in <code class="language-plaintext highlighter-rouge">web/</code> (Vite, service worker, Add to Home Screen)</li>
  <li><strong>Phoenix</strong> JSON API for magic-link auth, sync, and the weather proxy</li>
  <li><strong>Postgres</strong> on Fly, same neighborhood as OnRotation</li>
  <li>Dark by default. Low glare. No reminder nags.</li>
</ul>

<p>Diary contents never go to analytics. If Simple Analytics is on, it is anonymous pageviews with Do Not Track respected. In-app feedback posts to Discord without the diary attached. Delete account deletes the server copy.</p>

<h2 id="try-it">Try it</h2>

<p>Migrainular is live at <a href="https://migrainular.com">migrainular.com</a>. Sign in with email, add it to your home screen if you want, and the next time it hurts: pain, save, done.</p>]]></content><author><name>Nick Stalter</name></author><category term="product" /><category term="launch" /><category term="migrainular" /><category term="phoenix" /><category term="svelte" /><category term="pwa" /><category term="privacy" /><summary type="html"><![CDATA[A private, local-first migraine diary you can fill out in about 20 seconds—even on a bad day. Phoenix API, Svelte PWA, no ads, not medical advice.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://nickstalter.com/img/migrainular-home.png" /><media:content medium="image" url="https://nickstalter.com/img/migrainular-home.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Phoenix LiveView: Offline-ish list with localStorage and a follow-up event</title><link href="https://nickstalter.com/phoenix-liveview-offline-ish-reconnect-cache" rel="alternate" type="text/html" title="Phoenix LiveView: Offline-ish list with localStorage and a follow-up event" /><published>2026-02-15T00:00:00-05:00</published><updated>2026-02-15T00:00:00-05:00</updated><id>https://nickstalter.com/phoenix-liveview-offline-ish-reconnect-cache</id><content type="html" xml:base="https://nickstalter.com/phoenix-liveview-offline-ish-reconnect-cache"><![CDATA[<p>On mobile, locking the screen often drops the WebSocket or suspends the tab. When the user unlocks and returns to your LiveView, the process may have been torn down and <strong>mount runs again</strong>. If mount always shows a loading spinner and refetches from the server, the user sees a full refresh every time they check their list—annoying when they’re in the store and opening the app every few minutes.</p>

<p>This post shows a small pattern to make a list (or any data-heavy view) feel “offline-ish”: <strong>cache a serializable snapshot in localStorage</strong>, have the client <strong>push it to the server in a follow-up event</strong> (not in connect params—that can blow the URI length limit), and <strong>show it immediately</strong> while still refetching in the background. If decode or staleness check fails, you fall back to a normal load.</p>

<p>I used this for the shopping list in <a href="https://onrotation.app">OnRotation</a>; the same idea applies to any LiveView that remounts often on mobile.</p>

<h2 id="1-cache-shape">1. Cache shape</h2>

<p>Store a JSON object with whatever the template needs to render, plus a timestamp for staleness:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">entries</code> – array of items (e.g. <code class="language-plaintext highlighter-rouge">id</code>, <code class="language-plaintext highlighter-rouge">display_name</code>, <code class="language-plaintext highlighter-rouge">quantity</code>, <code class="language-plaintext highlighter-rouge">category</code>, <code class="language-plaintext highlighter-rouge">checked</code>, …)</li>
  <li>Any other assigns the template uses (e.g. <code class="language-plaintext highlighter-rouge">selected_week_start</code>, <code class="language-plaintext highlighter-rouge">week_meal_names</code>, <code class="language-plaintext highlighter-rouge">item_key_to_meal_names</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">cached_at</code> – ISO8601 timestamp so the server can ignore cache older than your TTL (e.g. 15 minutes)</li>
</ul>

<p>Use a single key per origin (e.g. <code class="language-plaintext highlighter-rouge">myapp_shopping_list</code>) if you have one list per user.</p>

<h2 id="2-server-push-cache-after-loading-or-updating">2. Server: push cache after loading or updating</h2>

<p>Whenever you assign the list (or related) data, push a serializable payload to the client so the hook can write it to localStorage. No structs or <code class="language-plaintext highlighter-rouge">DateTime</code> in the payload—plain maps and strings.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">defp</span> <span class="n">build_cache_payload</span><span class="p">(</span><span class="n">assigns</span><span class="p">)</span> <span class="k">do</span>
  <span class="p">%{</span>
    <span class="s2">"entries"</span> <span class="o">=&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">assigns</span><span class="o">.</span><span class="n">entries</span> <span class="o">||</span> <span class="p">[],</span> <span class="o">&amp;</span><span class="n">entry_to_cache</span><span class="o">/</span><span class="mi">1</span><span class="p">),</span>
    <span class="s2">"selected_week_start"</span> <span class="o">=&gt;</span> <span class="n">assigns</span><span class="o">.</span><span class="n">selected_week_start</span> <span class="o">&amp;&amp;</span> <span class="no">Date</span><span class="o">.</span><span class="n">to_iso8601</span><span class="p">(</span><span class="n">assigns</span><span class="o">.</span><span class="n">selected_week_start</span><span class="p">),</span>
    <span class="s2">"cached_at"</span> <span class="o">=&gt;</span> <span class="no">DateTime</span><span class="o">.</span><span class="n">utc_now</span><span class="p">()</span> <span class="o">|&gt;</span> <span class="no">DateTime</span><span class="o">.</span><span class="n">to_iso8601</span><span class="p">()</span>
    <span class="c1"># ... other keys the template needs</span>
  <span class="p">}</span>
<span class="k">end</span>

<span class="k">defp</span> <span class="n">push_cache</span><span class="p">(</span><span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">push_event</span><span class="p">(</span><span class="n">socket</span><span class="p">,</span> <span class="s2">"cache_shopping_list"</span><span class="p">,</span> <span class="n">build_cache_payload</span><span class="p">(</span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="p">))</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Call <code class="language-plaintext highlighter-rouge">push_cache(socket)</code> at the end of <code class="language-plaintext highlighter-rouge">handle_info(:load_plan, socket)</code> and after every <code class="language-plaintext highlighter-rouge">handle_event</code> that updates the list (toggle, add, delete, etc.).</p>

<h2 id="3-client-hook-to-write-cache">3. Client: hook to write cache</h2>

<p>A hook that listens for the event and stores the payload:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">CACHE_KEY</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">myapp_shopping_list</span><span class="dl">"</span>

<span class="kd">const</span> <span class="nx">ShoppingListCacheHook</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nf">mounted</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">handleEvent</span><span class="p">(</span><span class="dl">"</span><span class="s2">cache_shopping_list</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">payload</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">try</span> <span class="p">{</span>
        <span class="nx">localStorage</span><span class="p">.</span><span class="nf">setItem</span><span class="p">(</span><span class="nx">CACHE_KEY</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">payload</span><span class="p">))</span>
      <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">_</span><span class="p">)</span> <span class="p">{}</span>
    <span class="p">})</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Mount the hook on an element in your LiveView (e.g. a hidden div so it’s present when the list is rendered).</p>

<h2 id="4-client-send-cache-in-a-follow-up-event-not-connect-params">4. Client: send cache in a follow-up event (not connect params)</h2>

<p><strong>Do not</strong> put the cache in LiveSocket’s <code class="language-plaintext highlighter-rouge">params</code>. Those end up in the connection URL and can hit <strong>URI length limits</strong> (often 4–8KB), causing “request URI too long” errors and even infinite redirect/error loops when the list is large.</p>

<p>Instead, have the same hook that writes the cache <strong>push an event</strong> to the server when it mounts. The payload goes in the WebSocket message body, so size is not an issue:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">ShoppingListCacheHook</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nf">mounted</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nf">handleEvent</span><span class="p">(</span><span class="dl">"</span><span class="s2">cache_shopping_list</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">payload</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">try</span> <span class="p">{</span>
        <span class="nx">localStorage</span><span class="p">.</span><span class="nf">setItem</span><span class="p">(</span><span class="nx">CACHE_KEY</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">(</span><span class="nx">payload</span><span class="p">))</span>
      <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">_</span><span class="p">)</span> <span class="p">{}</span>
    <span class="p">})</span>
    <span class="c1">// Send cache to server in a follow-up event to avoid URI length limit</span>
    <span class="k">try</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">cached</span> <span class="o">=</span> <span class="nx">localStorage</span><span class="p">.</span><span class="nf">getItem</span><span class="p">(</span><span class="nx">CACHE_KEY</span><span class="p">)</span>
      <span class="k">if </span><span class="p">(</span><span class="nx">cached</span><span class="p">)</span> <span class="k">this</span><span class="p">.</span><span class="nf">pushEvent</span><span class="p">(</span><span class="dl">"</span><span class="s2">restore_cached_list</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">cache</span><span class="p">:</span> <span class="nx">cached</span> <span class="p">})</span>
    <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">_</span><span class="p">)</span> <span class="p">{}</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Keep <code class="language-plaintext highlighter-rouge">params: { _csrf_token: csrfToken }</code> (or a simple static object); no cache there.</p>

<h2 id="5-server-apply-cache-when-the-event-arrives">5. Server: apply cache when the event arrives</h2>

<p>Handle the event: if you’re still loading and the cache is valid, apply it and set your loading flag to <code class="language-plaintext highlighter-rouge">false</code>. You still run the refetch (e.g. <code class="language-plaintext highlighter-rouge">send(self(), :load_plan)</code> in mount); when it completes, assign fresh data and call <code class="language-plaintext highlighter-rouge">push_cache</code> again.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">@cache_ttl_seconds</span> <span class="mi">900</span>  <span class="c1"># 15 minutes</span>

<span class="k">def</span> <span class="n">mount</span><span class="p">(</span><span class="n">_params</span><span class="p">,</span> <span class="n">_session</span><span class="p">,</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">socket</span> <span class="o">=</span> <span class="n">assign_defaults</span><span class="p">(</span><span class="n">socket</span><span class="p">)</span>
  <span class="n">send</span><span class="p">(</span><span class="n">self</span><span class="p">(),</span> <span class="ss">:load_plan</span><span class="p">)</span>
  <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">socket</span><span class="p">}</span>
<span class="k">end</span>

<span class="k">def</span> <span class="n">handle_event</span><span class="p">(</span><span class="s2">"restore_cached_list"</span><span class="p">,</span> <span class="p">%{</span><span class="s2">"cache"</span> <span class="o">=&gt;</span> <span class="n">cache_str</span><span class="p">},</span> <span class="n">socket</span><span class="p">)</span> <span class="ow">when</span> <span class="n">is_binary</span><span class="p">(</span><span class="n">cache_str</span><span class="p">)</span> <span class="k">do</span>
  <span class="k">if</span> <span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">plan_loading?</span> <span class="k">do</span>
    <span class="k">case</span> <span class="n">try_apply_decoded_cache</span><span class="p">(</span><span class="n">socket</span><span class="p">,</span> <span class="n">cache_str</span><span class="p">)</span> <span class="k">do</span>
      <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">updated</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">assign</span><span class="p">(</span><span class="n">updated</span><span class="p">,</span> <span class="ss">:plan_loading?</span><span class="p">,</span> <span class="no">false</span><span class="p">)}</span>
      <span class="ss">:skip</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">socket</span><span class="p">}</span>
    <span class="k">end</span>
  <span class="k">else</span>
    <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">socket</span><span class="p">}</span>
  <span class="k">end</span>
<span class="k">rescue</span>
  <span class="n">_</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">socket</span><span class="p">}</span>
<span class="k">end</span>

<span class="k">def</span> <span class="n">handle_event</span><span class="p">(</span><span class="s2">"restore_cached_list"</span><span class="p">,</span> <span class="n">_params</span><span class="p">,</span> <span class="n">socket</span><span class="p">),</span> <span class="k">do</span><span class="p">:</span> <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">socket</span><span class="p">}</span>

<span class="k">defp</span> <span class="n">try_apply_decoded_cache</span><span class="p">(</span><span class="n">socket</span><span class="p">,</span> <span class="n">cache_str</span><span class="p">)</span> <span class="k">do</span>
  <span class="k">case</span> <span class="no">Jason</span><span class="o">.</span><span class="n">decode</span><span class="p">(</span><span class="n">cache_str</span><span class="p">)</span> <span class="k">do</span>
    <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">decoded</span><span class="p">}</span> <span class="ow">when</span> <span class="n">is_map</span><span class="p">(</span><span class="n">decoded</span><span class="p">)</span> <span class="o">-&gt;</span>
      <span class="k">if</span> <span class="n">cache_fresh?</span><span class="p">(</span><span class="n">decoded</span><span class="p">),</span> <span class="k">do</span><span class="p">:</span> <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">apply_cached_assigns</span><span class="p">(</span><span class="n">socket</span><span class="p">,</span> <span class="n">decoded</span><span class="p">)},</span> <span class="k">else</span><span class="p">:</span> <span class="ss">:skip</span>
    <span class="n">_</span> <span class="o">-&gt;</span> <span class="ss">:skip</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">defp</span> <span class="n">cache_fresh?</span><span class="p">(</span><span class="n">decoded</span><span class="p">)</span> <span class="k">do</span>
  <span class="k">case</span> <span class="n">decoded</span><span class="p">[</span><span class="s2">"cached_at"</span><span class="p">]</span> <span class="k">do</span>
    <span class="no">nil</span> <span class="o">-&gt;</span> <span class="no">false</span>
    <span class="n">iso</span> <span class="ow">when</span> <span class="n">is_binary</span><span class="p">(</span><span class="n">iso</span><span class="p">)</span> <span class="o">-&gt;</span>
      <span class="k">case</span> <span class="no">DateTime</span><span class="o">.</span><span class="n">from_iso8601</span><span class="p">(</span><span class="n">iso</span><span class="p">)</span> <span class="k">do</span>
        <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">dt</span><span class="p">,</span> <span class="n">_</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="no">DateTime</span><span class="o">.</span><span class="n">diff</span><span class="p">(</span><span class="no">DateTime</span><span class="o">.</span><span class="n">utc_now</span><span class="p">(),</span> <span class="n">dt</span><span class="p">,</span> <span class="ss">:second</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="nv">@cache_ttl_seconds</span>
        <span class="n">_</span> <span class="o">-&gt;</span> <span class="no">false</span>
      <span class="k">end</span>
    <span class="n">_</span> <span class="o">-&gt;</span> <span class="no">false</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Decode and apply: build <code class="language-plaintext highlighter-rouge">entries</code> (and any derived assigns like <code class="language-plaintext highlighter-rouge">entries_by_section</code>) from the decoded map. If anything fails (decode, missing keys, bad types), leave the socket as-is and let the refetch show the real data. No need for elaborate validation—fail fast and fall back.</p>

<h2 id="edge-cases">Edge cases</h2>

<ul>
  <li><strong>No cache</strong> – Behave as today: loading spinner, then load.</li>
  <li><strong>Stale cache</strong> – Ignore if <code class="language-plaintext highlighter-rouge">cached_at</code> is older than your TTL; load as normal.</li>
  <li><strong>Decode / structure error</strong> – Ignore cache and refetch. Defensive decode keeps bad or tampered data from breaking the view; the server remains the source of truth.</li>
</ul>

<h2 id="security-note">Security note</h2>

<p>The cache is only for UX: show something immediately. The server always re-fetches and scopes data by the authenticated session. Don’t trust the cache for authorization. For a shopping list or similar, storing it in localStorage is fine; for more sensitive data, consider whether you want it on the client at all.</p>

<h2 id="summary">Summary</h2>

<ol>
  <li>After each load or mutation, <code class="language-plaintext highlighter-rouge">push_event(..., "cache_...", payload)</code> with a serializable snapshot.</li>
  <li>A JS hook writes that payload to localStorage and, on mount, reads it and <code class="language-plaintext highlighter-rouge">pushEvent("restore_cached_list", { cache })</code> so the server gets it in a <strong>WebSocket message</strong> (not connect params—avoids URI length limits).</li>
  <li>Server handles <code class="language-plaintext highlighter-rouge">"restore_cached_list"</code>: if still loading and cache is valid (decode + TTL), apply it and set loading to false.</li>
  <li>Mount always triggers a refetch; when it completes, assign fresh data and push the new cache.</li>
</ol>

<p>With a short TTL (e.g. 15 minutes), users get an instant list when they reopen the app after a brief lock, and the list still refreshes in the background so it stays accurate.</p>

<p>For more on hooks and storage, see the earlier post <a href="https://distortia.github.io/2022/04/14/phoenix-liveview-with-localstorage-or-sessionstorage.html">Phoenix LiveView with localStorage or sessionStorage</a>; this one focuses on <strong>reconnect/mount</strong> and sending the cache in a <strong>follow-up pushEvent</strong> (not connect params) to avoid both the full refetch and URI length limits.</p>]]></content><author><name>Nick Stalter</name></author><category term="engineering" /><category term="elixir" /><category term="phoenix" /><category term="phoenix" /><category term="liveview" /><category term="elixir" /><category term="localStorage" /><category term="reconnect" /><category term="mobile" /><summary type="html"><![CDATA[Stop your LiveView from doing a full refresh every time the user unlocks their phone: cache list data in localStorage and restore it via a follow-up event (not connect params) while refetching in the background.]]></summary></entry><entry><title type="html">OnRotation: Technical Architecture of a Phoenix LiveView Meal Planning App</title><link href="https://nickstalter.com/onrotation-technical-architecture" rel="alternate" type="text/html" title="OnRotation: Technical Architecture of a Phoenix LiveView Meal Planning App" /><published>2026-02-14T00:00:00-05:00</published><updated>2026-02-14T00:00:00-05:00</updated><id>https://nickstalter.com/onrotation-technical-architecture</id><content type="html" xml:base="https://nickstalter.com/onrotation-technical-architecture"><![CDATA[<p><a href="https://onrotation.app">OnRotation</a> is a weekly meal planning app built with Phoenix and LiveView. I’ve written about the product story before; this post focuses on the technical architecture: how the app is structured, how data flows, and the patterns we rely on to keep it simple and maintainable.</p>

<h2 id="tech-stack">Tech Stack</h2>

<ul>
  <li><strong>Phoenix 1.8</strong> with <strong>LiveView</strong></li>
  <li><strong>Ecto + PostgreSQL</strong> for persistence</li>
  <li><strong>Tailwind CSS v4</strong> for styling (CSS variables, no config file)</li>
  <li><strong>Req</strong> for HTTP (no HTTPoison/Tesla)</li>
  <li><strong>Resend</strong> for transactional email (magic links, invites)</li>
  <li><strong>Sentry</strong> for error tracking</li>
</ul>

<p>Quality tooling: <strong>Credo</strong>, <strong>Dialyzer</strong>, <strong>SoBelow</strong> (security), all wired into <code class="language-plaintext highlighter-rouge">mix precommit</code>.</p>

<h2 id="architecture-overview">Architecture Overview</h2>

<p>The app follows a context-based structure. Core domains:</p>

<ul>
  <li><strong>Accounts</strong> – users, households, invites, magic-link auth</li>
  <li><strong>Meals</strong> – meal library, curated meals, meal packs</li>
  <li><strong>Plans</strong> – weekly plans and plan entries</li>
  <li><strong>ShoppingLists</strong> – per-household shopping list with plan-derived items</li>
  <li><strong>Blog</strong> – public blog with Earmark-based body processing</li>
</ul>

<p>Almost all domain data is scoped by <strong>household</strong>. A user belongs to one household; meals, plans, and shopping lists are all keyed by <code class="language-plaintext highlighter-rouge">household_id</code>. That keeps queries straightforward and authorization simple.</p>

<h2 id="the-scope-pattern">The Scope Pattern</h2>

<p>We use an <code class="language-plaintext highlighter-rouge">OnRotation.Accounts.Scope</code> struct to represent the caller’s context:</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">defstruct</span> <span class="ss">user:</span> <span class="no">nil</span><span class="p">,</span> <span class="ss">household:</span> <span class="no">nil</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">phx.gen.auth</code> gives us <code class="language-plaintext highlighter-rouge">current_scope</code>, not <code class="language-plaintext highlighter-rouge">current_user</code>. The scope carries both <code class="language-plaintext highlighter-rouge">user</code> and <code class="language-plaintext highlighter-rouge">household</code>, so controllers and LiveViews can consistently pass <code class="language-plaintext highlighter-rouge">current_scope</code> into contexts. Context functions take <code class="language-plaintext highlighter-rouge">scope</code> or <code class="language-plaintext highlighter-rouge">household_id</code> as the first argument and scope all queries by household.</p>

<p>This avoids leaking user-only logic into contexts and keeps “who is acting” and “on which household” explicit.</p>

<h2 id="routing-and-pipelines">Routing and Pipelines</h2>

<p>We use separate pipelines and live sessions for different auth levels:</p>

<ul>
  <li><strong>Public</strong> – marketing, blog, sitemap, robots (browser_and_seo)</li>
  <li><strong>Auth routes</strong> – log-in (magic link), registration</li>
  <li><strong>Authenticated</strong> – plan, meals, shopping list, feedback (live_session :authenticated)</li>
  <li><strong>Blog admin</strong> – create/edit blog posts (require_blog_author)</li>
  <li><strong>Meal pack admin</strong> – manage curated meal packs (require_meal_pack_admin)</li>
  <li><strong>Admin</strong> – metrics dashboard (require_admin)</li>
</ul>

<p>The <strong>API pipeline</strong> (<code class="language-plaintext highlighter-rouge">/api/v1</code>) accepts JSON and uses Bearer token auth. It’s used by the Android app; web users stay in LiveView.</p>

<h2 id="authentication">Authentication</h2>

<p>We’re privacy-focused: no passwords, magic links only, and Simple Analytics for basic analytics instead of tracking-heavy alternatives. No cross-site tracking, no ad networks.</p>

<p>Web users sign in via magic links. The flow:</p>

<ol>
  <li>User enters email.</li>
  <li>We create or find the user and send a tokenized link (Resend).</li>
  <li>User clicks the link; we validate the token and establish a session.</li>
</ol>

<p>For the mobile API:</p>

<ol>
  <li>Client calls <code class="language-plaintext highlighter-rouge">POST /api/v1/auth/magic-link</code> with email.</li>
  <li>User receives the link and opens it in a browser; we redirect to a page that calls the API to exchange the token for a Bearer token.</li>
  <li>Client stores the Bearer token and sends it on subsequent requests.</li>
</ol>

<p>Both flows end up with a session token; web uses cookies, API uses <code class="language-plaintext highlighter-rouge">Authorization: Bearer &lt;token&gt;</code>.</p>

<h2 id="data-model">Data Model</h2>

<p>The main entities:</p>

<ul>
  <li><strong>Household</strong> – one per account; owns meals, plans, shopping list</li>
  <li><strong>Meal</strong> – name, recipe_url, main_ingredients, extras, tags, complexity (1–3)</li>
  <li><strong>Plan</strong> – one per week per household (<code class="language-plaintext highlighter-rouge">week_start_date</code> = Monday)</li>
  <li><strong>PlanEntry</strong> – links plan to meal, with <code class="language-plaintext highlighter-rouge">day_index</code> (0=Mon … 6=Sun) and position for multiple meals per day</li>
  <li><strong>ShoppingListEntry</strong> – per-household; entries come from plan merge or manual “extras”</li>
</ul>

<p>Meals, plans, and shopping lists are always queried with <code class="language-plaintext highlighter-rouge">household_id</code> in the <code class="language-plaintext highlighter-rouge">WHERE</code> clause.</p>

<h2 id="shopping-list-plan-merge-and-categorization">Shopping List: Plan Merge and Categorization</h2>

<p>The shopping list has two sources of items:</p>

<ol>
  <li><strong>Plan-derived</strong> – parsed from <code class="language-plaintext highlighter-rouge">main_ingredients</code> and <code class="language-plaintext highlighter-rouge">extras</code> of meals in the current week’s plan</li>
  <li><strong>Manual extras</strong> – user-added items (e.g. paper towels, milk)</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">merge_plan_into_list/2</code> takes the current week’s plan, parses ingredients into segments (comma/semicolon), normalizes them into <code class="language-plaintext highlighter-rouge">item_key</code>s for grouping, and upserts into <code class="language-plaintext highlighter-rouge">ShoppingListEntry</code> by <code class="language-plaintext highlighter-rouge">(household_id, item_key)</code>. Existing rows are never deleted by the merge—only added or updated.</p>

<p>Categorization is done by <code class="language-plaintext highlighter-rouge">ShoppingLists.Categorizer</code>: whole-word keyword matching against produce, meat/seafood, cheese/dairy, pantry, and a default “items” bucket. Priority order matters (e.g. “egg noodles” matches pantry before cheese) so we can sort the list in a sensible shopping order.</p>

<h2 id="liveview-and-ui">LiveView and UI</h2>

<p>Core features (plan, meals, shopping list) are LiveView pages. We use:</p>

<ul>
  <li><strong>Streams</strong> for large, appendable lists to avoid memory growth</li>
  <li><strong>Functional components</strong> (CoreComponents) for forms, buttons, layout</li>
  <li><strong>Colocated JS hooks</strong> when we need client behavior (e.g. theme toggle in localStorage)</li>
</ul>

<p>All LiveView templates start with <code class="language-plaintext highlighter-rouge">&lt;Layouts.app flash={@flash} ...&gt;</code> and receive <code class="language-plaintext highlighter-rouge">current_scope</code> from the <code class="language-plaintext highlighter-rouge">live_session</code> so they can pass it into context calls.</p>

<h2 id="design-system">Design System</h2>

<p>Colors and typography live in <code class="language-plaintext highlighter-rouge">DESIGN.md</code> and CSS variables (<code class="language-plaintext highlighter-rouge">var(--onrotation-primary)</code>, etc.). Light/dark themes use <code class="language-plaintext highlighter-rouge">data-theme="light"</code> and <code class="language-plaintext highlighter-rouge">data-theme="dark"</code>; we also support “system” via <code class="language-plaintext highlighter-rouge">prefers-color-scheme</code>. Theme choice is persisted in localStorage and applied before paint when possible to avoid flash.</p>

<p>Transactional emails share the same layout and brand colors, with inline styles for email client compatibility.</p>

<h2 id="blog-and-content">Blog and Content</h2>

<p>The blog is a separate context (<code class="language-plaintext highlighter-rouge">OnRotation.Blog</code>) with published posts, tags, and an author-scoped admin. Body content is stored as Markdown in the database and rendered with Earmark at request time. A <code class="language-plaintext highlighter-rouge">BodyProcessor</code> runs after Earmark to replace <code class="language-plaintext highlighter-rouge">{{meal:N}}</code> shortcodes with rendered meal cards—so authors can embed curated meals in posts, and logged-in users can add them to their library in place.</p>

<p>The blog admin is a LiveView (<code class="language-plaintext highlighter-rouge">BlogAdminLive</code>) behind a <code class="language-plaintext highlighter-rouge">require_blog_author</code> plug. Authors get a list view, create/edit with autosave draft, tag management, and a preview that matches the public layout. Public routes are controller-based: index (optionally filtered by tag), show by slug, and an RSS feed at <code class="language-plaintext highlighter-rouge">/blog/feed.xml</code>. The sitemap includes blog posts for SEO. Slugs are derived from titles; tags are many-to-many and filterable on the index.</p>

<h2 id="api-for-mobile">API for Mobile</h2>

<p><code class="language-plaintext highlighter-rouge">/api/v1</code> exposes resources for meals, plans, and plan entries. The API uses the same context functions as the web app; the only difference is auth (Bearer tokens) and response format (JSON). A plug assigns <code class="language-plaintext highlighter-rouge">household_id</code> from the authenticated user so controllers can call contexts like <code class="language-plaintext highlighter-rouge">Meals.list_meals(household_id, opts)</code> without extra logic.</p>

<h2 id="quality-and-consistency">Quality and Consistency</h2>

<p>We enforce consistency with:</p>

<ul>
  <li><strong>One <code class="language-plaintext highlighter-rouge">alias</code> per line</strong> – no <code class="language-plaintext highlighter-rouge">alias Foo.{Bar, Baz}</code></li>
  <li><strong><code class="language-plaintext highlighter-rouge">@moduledoc</code> and <code class="language-plaintext highlighter-rouge">@spec</code> on public functions</strong></li>
  <li><strong>No <code class="language-plaintext highlighter-rouge">maybe_</code> prefix</strong> – use descriptive names instead</li>
  <li><strong><code class="language-plaintext highlighter-rouge">mix precommit</code></strong> – format, Credo, SoBelow, Dialyzer, tests</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">AGENTS.md</code> and <code class="language-plaintext highlighter-rouge">.cursor/rules</code> files document these conventions for humans and AI-assisted workflows.</p>

<h2 id="summary">Summary</h2>

<p>OnRotation is a Phoenix app where:</p>

<ul>
  <li>Household-scoped contexts and a <code class="language-plaintext highlighter-rouge">Scope</code> struct keep authorization and data access clear.</li>
  <li>LiveView drives the web UX with streams and functional components.</li>
  <li>A small JSON API under <code class="language-plaintext highlighter-rouge">/api/v1</code> serves mobile with the same business logic.</li>
  <li>Magic links power both web and mobile auth.</li>
  <li>A token-based design system and precommit checks keep the codebase consistent and maintainable.</li>
</ul>

<p>If you’re building something similar—household/multi-tenant, LiveView-heavy, with a mobile API—this architecture is a solid starting point. The main lesson: invest in scoping and context boundaries early; they make everything else easier.</p>

<p><strong><a href="https://onrotation.app">Try OnRotation at onrotation.app</a></strong> — start with our Starter Pack and plan your week in minutes.</p>]]></content><author><name>Nick Stalter</name></author><category term="engineering" /><category term="elixir" /><category term="phoenix" /><category term="phoenix" /><category term="liveview" /><category term="elixir" /><category term="ecto" /><category term="architecture" /><summary type="html"><![CDATA[A technical walkthrough of the architecture behind OnRotation—household-scoped contexts, LiveView-heavy UX, a JSON API for mobile, and the patterns that keep it maintainable.]]></summary></entry></feed>