<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>destroytoday.com (Cushion)</title>
        <link>https://destroytoday.com/feeds/cushion</link>
        <description>Blog posts by Jonnie Hallman (@destroytoday), categorized under “Cushion”</description>
        <lastBuildDate>Sun, 12 Apr 2026 11:46:25 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/nuxt-community/feed-module</generator>
        <item>
            <title><![CDATA[Eating my own dogfood again]]></title>
            <link>https://destroytoday.com/blog/eating-my-own-dogfood-again</link>
            <guid>https://destroytoday.com/blog/eating-my-own-dogfood-again</guid>
            <pubDate>Sat, 11 Apr 2026 11:59:00 GMT</pubDate>
            <description><![CDATA[Now that I’m freelancing full-time, and using Cushion again, I’ve fallen in love with it all over again, but I also feel every rough spot.]]></description>
            <content:encoded><![CDATA[<p>Now that <a href='/blog/going-independent-again'>I’ve gone independent</a> after a 6+ year hiatus in the full-time world, I’m now freelancing again, which also means I’m actively using <a href="https://cushionapp.com">Cushion</a> again. At first, I only needed to track time and invoice for <a href='/blog/animating-quines-for-larva-labs'>a friend project</a>, but now that I’m 100% on my own, I need to make sure I’m on track financially for the year—that’s where Cushion really clicks. </p><p>As soon as I started using Cushion again as a full-time freelancer, I fell in love with it all over again. I also realized that it still holds up after all these years! As a returning <em>user</em>, however, I immediately noticed a few rough spots since I last freelanced. These are the kind of rough spots that I’d otherwise miss if I weren’t a user of my own app—and I did miss them. After reminding myself that I can actually fix the rough spots that I come across, I spent an evening smoothing them out. </p><p>The next day, I used Cushion as I normally would, but with the usual pain points no longer there. I felt an instant jolt of delight when an especially cumbersome flow in my daily routine was reduced to a single click. If I were a regular user, I would’ve never bothered to reach out to support to mention the extra clicks in this flow, but I’d still feel them chipping away at me. Rough spots like this could’ve worn me down enough that I might eventually fall <em>out</em> of love with the app. But because I work on Cushion—and now use it regularly—as soon as I feel a rough spot myself, I want to fix it immediately because I know other users feel it, too, …but not enough to report it. This prompted me to send a message to my users, giving them the chance to share any rough spots they experience in their day-to-day use of Cushion, but haven’t bothered to mention.</p><p>Unsurprisingly, I heard back from a handful of users with plenty of rough spots to keep me busy for a while. The biggest surprise, however, was the number of users who replied that they couldn’t think of any. (Some Cushion users are too nice to give it to me straight!) While I appreciate the kindness, I’m craving feedback right now. I spent the next few days recording all the rough spots into a to-do list. Now I have the low-hanging fruit I need to build some momentum as I get back in the saddle. </p><p>I’m not a “Let’s fucking go!” guy, but now would be the time to say it.</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Eating%20my%20own%20dogfood%20again">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Cleaning up the cobwebs]]></title>
            <link>https://destroytoday.com/blog/cleaning-up-the-cobwebs</link>
            <guid>https://destroytoday.com/blog/cleaning-up-the-cobwebs</guid>
            <pubDate>Thu, 29 Aug 2024 12:35:00 GMT</pubDate>
            <description><![CDATA[After years of side-eyeing an unreleased feature that has been nagging me while slowing down the app, I finally take the time to remove it from the app.]]></description>
            <content:encoded><![CDATA[<p>Lately, I’ve felt the need to be proactive about cleaning up parts of <a href="https://cushionapp.com">Cushion</a> that either no longer serve a purpose, slow the app down, or both. Top on this list for a while sits a feature that I actually never released out of a (very) private beta—only five-ish users were trying it out and this was years ago. The feature was a swing-for-the-fences to potentially grow Cushion exponentially through what folks call “network effect” in the startup biz. It didn’t work out for a number of reasons—and I’m glad it didn’t—but its footprint touched so much of the app that by the time I was ready to remove it, I knew it’d be a significant undertaking. The feature is what I call “collaboration”.</p><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?fm=webp&w=1440&q=80'>
            <img
              alt='Kill collaboration screenshot'
              src='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?w=1440&q=80'
              width='720'
              height='336.5217391304348'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>Collaboration in Cushion was like “teams for freelancers”, but there were no dedicated teams. Any user could invite any <em>other</em> user to a project. Or they could invite a non-Cushion  user to a project solely to track their time, like a subcontractor. Then when it came time to create an invoice, they could include their own tracked time along with the subcontractors’. The original user would simply pay extra for “seats” for their collaborators, and that would be an extra $5 per seat per month or so. By inviting subcontractors to a project, this would potentially encourage more non-Cushion users to start using Cushion. This is that “network effect” I mentioned, combined with a way to get more monthly recurring revenue out of a user—especially since a handful of folks have begged for multiple users in the past.</p><p>On paper, it sounded like a great idea that makes complete sense. In the implementation, I think we went about it the wrong way both on the backend and on the frontend. Instead of taking the straightforward approach of introducing a separate parent “team” model that could have many users and be the owner of the shared projects, we decided to shoehorn the database associations for collaboration into the existing user model. With this approach, a user could have “members” (users) and “readable” and “editable” datasets for anything that’s shared. Then for anything created, like time tracking entries or workloads, the user ID for these would be the member, but model would also be tied to the parent user. <em>This</em> led to an entire layer of complexity added to almost every database query that involved these models. And <em>that</em> led to slower database queries for <em>everyone</em> in the app—not only folks using collaboration.</p><p>On the frontend, “collaboration” was its entirely own section with subsections for all the existing top-level sections—scheduling, time-tracking, etc. This essentially doubled the footprint of the app, but in a way that <em>felt</em> duplicated instead of built-in or well-thought-out. Again, if I had only followed the straight-forward approach of a team-like model where you could view different workspaces, ala Slack, it would be much more straightforward—especially if one of those workspaces is your own personal one. </p><p>In hindsight, the correct solution seems so simple, but I need to remember that six years of time and experience has accumulated since then. Of course, it seems simple now, but it didn’t then—especially in the stressful situation of needing a home run. I can’t knock myself for that, but on the glass-half-full perspective, at least I didn’t actually launch collaboration, get loads of folks to rely on it, then realize it wasn’t right for the app. And, if I ever wanted to revisit the concept with a more straightforward approach, I now have the wherewithal to do it in steps. First, I’d introduce the concept of workspaces for individual users, so they could have multiple businesses. <em>Then</em>, I could introduce the ability to invite other users to those workspaces. <em>That</em> would be the correct approach. </p><p>As much as I want to give it another shot, I first need to make Cushion perfect for the individual. Then I could reconsider collaboration. With this in mind, I’m determined to be much more consistent with user research going forward, so I could polish all the rough spots that nag folks on a regular basis. If you’re eager to share your thoughts, I’ve re-enabled chat in the app. Hit me up!</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Cleaning%20up%20the%20cobwebs">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a table for the nth time - Part 2]]></title>
            <link>https://destroytoday.com/blog/building-a-table-for-the-nth-time-part-2</link>
            <guid>https://destroytoday.com/blog/building-a-table-for-the-nth-time-part-2</guid>
            <pubDate>Wed, 10 Jan 2024 13:23:00 GMT</pubDate>
            <description><![CDATA[Following up on the initial post about building a table in Cushion, I actually detail my approach this time.]]></description>
            <content:encoded><![CDATA[<p>In the <a href='/blog/building-a-table-for-the-nth-time'>last post about tables</a>, I revisited all the tables I built for <a href="https://cushionapp.com">Cushion</a> over the past 10 years and described both the tech and approach I used. These tables relied on the tech that was available to me at the time, which resulted in tables built with jQuery, CoffeeScript, Angular, and old versions of Vue. Now that I’ve been using Vue 3’s Composition API and TypeScript for several years, I’m especially comfortable and confident in this most recent approach (which is always the way it should be).</p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=960&w=1332&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=960&w=1332&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=1280&w=1332&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=1280&w=1332&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=1332&q=80'>
            <img
              alt='Vue 3 table'
              src='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=1332&q=80'
              width='666'
              height='379.3157360406091'
              style='width: 100%; max-width: 666px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>When I first started building the table, I knew I wanted to lean on markup as much as I could. I’m a purest at heart, so I prefer writing HTML when I need HTML and CSS when I need CSS. If you read the previous post, I’ve certainly had my fair share of “clever” approaches to rendering HTML, like concatenating strings, and I’m <em>so</em> over that phase of my engineering life. Luckily, Vue 3 is incredibly fast when it comes to rendering, so I don’t need to worry about performance the way I did with Angular back in the day. Now, I can just write the markup, wire up the data, and expect instant rendering.</p><p>Since I knew I’d be reusing this table throughout Cushion, I decided to make a “base” table that I could use across clients, projects, and invoices, but I wanted to make sure it was specific to “items”—not an entirely generic base table. I’ll explain. There’s bound to be areas where I need a table that doesn’t look like the tables for invoices, etc., and doesn’t use the kind of data that goes into an invoice table, so instead of making a <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;Table&gt;</code> component and calling it a day, I opted for an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTable&gt;</code> that’s more intentional in its use and infers that this is a table for “items”. (I admit “item” is an incredibly ambiguous word here, but I strangely don’t like using the word “model”, which is what I’m referring to—a model with an ID.)</p><p>From here, I was able to build all the child components for an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>ItemTable</code>, which include an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTableCell&gt;</code> (or <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;td&gt;</code>), <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTableHeader&gt;</code> (or <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;th&gt;</code>), and <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTableRow&gt;</code> (or <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;tr&gt;</code>). These components are intentionally primitive because they’re meant to be extended. Within the components themselves, however, they’re styled to the design of the item table. They also handle all the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>aria-role</code> attributes, so I can maintain accessibility while ejecting from the table-based <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>display</code> styles. With these low-level components, I can… </p><ul><li><p>extend the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTable&gt;</code> to make an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;InvoiceTable&gt;</code> that takes an array of invoices and renders them as invoice rows</p></li><li><p>extend the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTableRow&gt;</code> to make an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;InvoiceTableRow&gt;</code> that takes an invoice and renders the relevant cells</p></li><li><p>extend the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTableCell&gt;</code> to make a collection of cells to handle any formatting needs, like currency, dates, durations, etc. </p></li></ul><p>All of this combined lets me easily compose tables while compartmentalizing their logic and styling. I no longer need a long config object full of callbacks to determine everything, like in past attempts. I do still have an initial config, but it’s limited—in a good way—and makes much more sense now through the use of composables (or hooks in React).</p><p>The last time I rebuilt this table, in 2017, the concept of composables didn’t even exist. Now, I’m able to configure a table with a `useTable` composable that takes an array of columns (for config), an array of rows (for data), and an optional order (column name and direction), then returns reactive arrays for the filtered columns and sorted rows. 

<pre class='language-ts'><code><span class="token keyword">const</span> <span class="token punctuation">{</span> columns<span class="token punctuation">,</span> rows <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useTable</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  columns<span class="token operator">:</span> <span class="token punctuation">[</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Color"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Color"</span><span class="token punctuation">,</span> <span class="token function-variable function">sortKey</span><span class="token operator">:</span> <span class="token punctuation">(</span>invoice<span class="token punctuation">)</span> <span class="token operator">=></span> invoice<span class="token punctuation">.</span>client<span class="token operator">?.</span>color <span class="token operator">||</span> <span class="token string">"#bbb"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Number"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"String"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"number"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Client"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"String"</span><span class="token punctuation">,</span> <span class="token function-variable function">sortKey</span><span class="token operator">:</span> <span class="token punctuation">(</span>invoice<span class="token punctuation">)</span> <span class="token operator">=></span> invoice<span class="token punctuation">.</span>client<span class="token operator">?.</span>name <span class="token operator">||</span> <span class="token string">"(no client)"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Created"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Date"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"created_at"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Updated"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Date"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"updated_at"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Sent"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Date"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"sent_on"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Due"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Date"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"due_on"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Paid"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Date"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"paid_on"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Amount"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Currency"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"total"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
  <span class="token punctuation">]</span><span class="token punctuation">,</span>
  rows<span class="token operator">:</span> invoices<span class="token punctuation">,</span>
  order<span class="token operator">:</span> <span class="token punctuation">{</span>column<span class="token operator">:</span> <span class="token string">"Amount"</span><span class="token punctuation">,</span> direction<span class="token operator">:</span> <span class="token string">"Desc"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span></code></pre></p><p>The config for the columns includes the column name as well as its type and sort key, which is either a key on the model or a callback to dig deeper. Callbacks are handy for specific cases where the sort key isn’t the raw property value. In Cushion’s case, sorting by color—which is a thing—requires that I convert the hex colors into HSL colors and sort by hue. As for “type”, I’m especially excited about this approach because it removes so much manual work. As an example, if a column is of type “date” or “currency”, the table knows to align those columns to the right and narrow their widths. Or, for a color-typed column, I could actually pass the raw hex color as the sort key, like above, and the column could know to automatically convert it to a hue before sorting.</p><p><pre class='language-ts'><code><span class="token keyword">const</span> <span class="token punctuation">{</span> columns<span class="token punctuation">,</span> rows <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useTable</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  <span class="token operator">...</span>
  includedColumns<span class="token operator">:</span> <span class="token punctuation">[</span><span class="token string">"Color"</span><span class="token punctuation">,</span> <span class="token string">"Number"</span><span class="token punctuation">,</span> <span class="token string">"Client"</span><span class="token punctuation">,</span> <span class="token string">"Amount"</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span></code></pre></p><p>If anyone’s especially curious, you might be wondering why this composable would need the array of columns only to return them again. This is because it also takes an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>includedColumns</code> property, which is an array of the column names to show. In Cushion, there are actually three invoice tables under the “Invoices” tab—“Drafts”, “Invoiced”, and “Paid”. All of these tables render invoice rows, but each of these tables show different columns that are relevant to the invoices’ status (e.g., a due date for the “Invoiced” table and a paid date for the “Paid table”).</p><p><pre class='language-ts'><code><span class="token keyword">const</span> <span class="token punctuation">{</span> columns<span class="token punctuation">,</span> rows <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useTable</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  <span class="token operator">...</span>
  includedColumns<span class="token operator">:</span> <span class="token punctuation">[</span><span class="token string">"Color"</span><span class="token punctuation">,</span> <span class="token string">"Number"</span><span class="token punctuation">,</span> <span class="token string">"Client"</span><span class="token punctuation">,</span> <span class="token operator">...</span>props<span class="token punctuation">.</span>includedColumns<span class="token punctuation">,</span> <span class="token string">"Amount"</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
  order<span class="token operator">:</span> props<span class="token punctuation">.</span>order<span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span></code></pre></p><p>In the past, these were three separate tables, which meant a lot of copy/pasting and reusing code on a column-by-column basis. This time around, however, I’m actually thrilled with the idea of using a single <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;InvoiceTable&gt;</code> component that by default includes <em>all</em> of the possible columns that an invoice table could have. Then, I can specify which columns to show—solely by name—as well as which column to sort by. This makes the actual implementation of each invoice table <em>incredibly</em> simple because the code is only several lines of markup whereas before each table had its own file with a full config. Also, now that Vue <a href="https://github.com/vuejs/rfcs/discussions/436">supports generically typed props</a>, composing these tables is much easier and type-safe because the table knows which columns and specific model it supports.</p><p><pre class='language-html'><code><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>InvoicesTable</span>
  <span class="token attr-name">title</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>Paid<span class="token punctuation">"</span></span>
  <span class="token attr-name">emptyMessage</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>No paid invoices<span class="token punctuation">"</span></span>
  <span class="token attr-name">:invoices</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>paidInvoices<span class="token punctuation">"</span></span>
  <span class="token attr-name">:includedColumns</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>['Sent', 'Paid']<span class="token punctuation">"</span></span>
  <span class="token attr-name">:order</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>{column: 'Paid', direction: 'Desc' }<span class="token punctuation">"</span></span>
<span class="token punctuation">/></span></span></code></pre></p><p>While I’m really happy about this approach, I admit I’ve only dealt with rendering so far, which is the first step, but it’s the easy one. Next up, I’ll need to dive into interactions, like context menus and drag-and-drop. I’m not worried about these, but I know full well that they’re pivotal decision moments. Can I still maintain a “pure” approach that’s clean, reusable, and intuitive? I think so! And that’s my goal.</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Building%20a%20table%20for%20the%20nth%20time%20-%20Part%202">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to use Playwright with GitHub Actions for e2e testing of Vercel preview deployments]]></title>
            <link>https://destroytoday.com/blog/how-to-use-playwright-with-github-actions-for-e2e-testing-of-vercel-preview</link>
            <guid>https://destroytoday.com/blog/how-to-use-playwright-with-github-actions-for-e2e-testing-of-vercel-preview</guid>
            <pubDate>Tue, 02 Jan 2024 14:00:00 GMT</pubDate>
            <description><![CDATA[I spent way too long trying to find out how to set up e2e testing with Vercel preview deploys, so I wrote a quick post to save others.]]></description>
            <content:encoded><![CDATA[<p>I normally wouldn’t write such a quick technical post with barely any story involved, but I spent an entire night trudging through outdated Stack Overflow answers, conflicting documentation, and surprisingly limited search results, then I stumbled upon <a href="https://vercel.com/guides/how-can-i-run-end-to-end-tests-after-my-vercel-preview-deployment">this Vercel guide</a> that seemed like it didn’t want to be found. To help steer the search results toward a simple solution that actually works, I feel obligated to contribute a post on how to use <a href="https://playwright.dev/">Playwright</a> with GitHub Actions for e2e testing of <a href="https://vercel.com/docs/deployments/preview-deployments">Vercel preview deployments</a>.</p><p>Here’s the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>e2e.yml</code> workflow file that you need to add to your project’s <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>.github/workflows</code> directory (assuming you’re using Node v20 and <a href="https://pnpm.io/">pnpm</a>):</p><p><pre class='language-yaml'><code><span class="token key atrule">name</span><span class="token punctuation">:</span> End<span class="token punctuation">-</span>to<span class="token punctuation">-</span>end testing
<span class="token key atrule">on</span><span class="token punctuation">:</span>
  <span class="token key atrule">deployment_status</span><span class="token punctuation">:</span>
<span class="token key atrule">jobs</span><span class="token punctuation">:</span>
  <span class="token key atrule">e2e</span><span class="token punctuation">:</span>
    <span class="token key atrule">if</span><span class="token punctuation">:</span> github.event_name == 'deployment_status' <span class="token important">&amp;&amp;</span> github.event.deployment_status.state == 'success'
    <span class="token key atrule">timeout-minutes</span><span class="token punctuation">:</span> <span class="token number">60</span>
    <span class="token key atrule">runs-on</span><span class="token punctuation">:</span> ubuntu<span class="token punctuation">-</span>latest
    <span class="token key atrule">steps</span><span class="token punctuation">:</span>
    <span class="token punctuation">-</span> <span class="token key atrule">uses</span><span class="token punctuation">:</span> actions/checkout@v3
    <span class="token punctuation">-</span> <span class="token key atrule">uses</span><span class="token punctuation">:</span> actions/setup<span class="token punctuation">-</span>node@v3
      <span class="token key atrule">with</span><span class="token punctuation">:</span>
        <span class="token key atrule">node-version</span><span class="token punctuation">:</span> 20.x
    <span class="token punctuation">-</span> <span class="token key atrule">name</span><span class="token punctuation">:</span> Install dependencies
      <span class="token key atrule">run</span><span class="token punctuation">:</span> npm install <span class="token punctuation">-</span>g pnpm <span class="token important">&amp;&amp;</span> pnpm install
    <span class="token punctuation">-</span> <span class="token key atrule">name</span><span class="token punctuation">:</span> Install Playwright Browsers
      <span class="token key atrule">run</span><span class="token punctuation">:</span> pnpm exec playwright install <span class="token punctuation">-</span><span class="token punctuation">-</span>with<span class="token punctuation">-</span>deps
    <span class="token punctuation">-</span> <span class="token key atrule">name</span><span class="token punctuation">:</span> Run Playwright tests
      <span class="token key atrule">env</span><span class="token punctuation">:</span>
        <span class="token key atrule">BASE_URL</span><span class="token punctuation">:</span> $<span class="token punctuation">{</span><span class="token punctuation">{</span> github.event.deployment_status.environment_url <span class="token punctuation">}</span><span class="token punctuation">}</span>
      <span class="token key atrule">run</span><span class="token punctuation">:</span> pnpm exec playwright test
</code></pre></p><p>This should read pretty easily if you’re familiar with GitHub Actions, but it triggers upon a deployment status change and only runs Playwright if the deploy is successful.</p><p>In your Playwright config, point the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>use.baseURL</code> property to your <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>BASE_URL</code> environment variable, which represents the generated URL for the preview deployment:</p><p><pre class='language-ts'><code><span class="token keyword">export</span> <span class="token keyword">default</span> <span class="token function">defineConfig</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  use<span class="token operator">:</span> <span class="token punctuation">{</span>
    baseURL<span class="token operator">:</span> process<span class="token punctuation">.</span>env<span class="token punctuation">.</span><span class="token constant">BASE_URL</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span></code></pre></p><p>Finally, push to GitHub. Assuming you have automatic deploys set up with Vercel and GitHub, you’ll see the results of your deploy in your pull request, then upon a successful deploy, the e2e workflow will start running—pointed at your preview deploy. It’s this easy. Don’t be like me and go down the outdated path of <em>waiting</em> for Vercel deploys or deploying <em>from</em> GitHub Actions. You don’t need that. And if you do, you don’t need this post.</p><p>Also, If Playwright seems to stall when running, it’s probably because you have <a href="https://vercel.com/docs/security/deployment-protection/methods-to-protect-deployments/vercel-authentication">Vercel Authentication</a> enabled in your Deployment Protection settings. You’ll either want to pay Vercel $150/mo for <a href="https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation">Protection Bypass for Automation</a>, or disable authentication. Unless you’re working on something super secret, disabling authentication is fine. And if it <em>is</em> super secret, you can afford $150/mo.</p><p>Hope this helps someone.</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20How%20to%20use%20Playwright%20with%20GitHub%20Actions%20for%20e2e%20testing%20of%20Vercel%20preview%20deployments">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a table for the nth time]]></title>
            <link>https://destroytoday.com/blog/building-a-table-for-the-nth-time</link>
            <guid>https://destroytoday.com/blog/building-a-table-for-the-nth-time</guid>
            <pubDate>Sat, 30 Dec 2023 14:01:00 GMT</pubDate>
            <description><![CDATA[Now that I have data to wire up, I set out to build a table component, but first I revisit past attempts.]]></description>
            <content:encoded><![CDATA[<p>Now that I have the <a href='/blog/a-return-to-tabbed-navigation'>navigation</a> roughed-in for the <a href='/blog/imagining-cushion-circa-2016-in-2024'>Cushion rethink</a>, I can start building my first feature-based view. Since the goal of this exercise is to return to Cushion’s prime—with a side-goal of building the perfect Cushion for <a href="https://www.jenmussari.com/">my wife</a>, a freelance lettering artist who wants to do admin work as little as possible—I’m focusing on invoicing and income tracking. These are the two aspects of freelancing that <em>every</em> freelancer needs to do, so it’s a solid target. When I think about where to start, I need to strip everything down and think in terms of usable milestones. While the visualizations are the most compelling part of Cushion’s design, they’re not the most useful on their own. These at-a-glance visuals can only tell you so much before you need to dive into the details. This leads me to the tables.</p><p>If I’m looking to build a working app sooner than later—and I’ve already proof-of-concept’d out the fun-yet-uncertain parts—then I can start with the boring-yet-essential parts. These tables are basically spreadsheets fed from the database. They ingest the data, display it, and add it all up in a way that’s useful for freelancers. At the end of the day, when a user is looking for a specific date, invoice amount, or payment status, they’re going to look at the tables first. Then, for a quick look at their progress over the year, they’ll check out the visualizations.</p><p>Believe it or not, I’ve actually built half a dozen versions of these tables for Cushion over the years, and what I’ve realized from revisiting the code is that my age and experience is directly correlated with my “cleverness”—or lack thereof. The younger I was when writing the code, the more roundabout my approach was. As I got older, my code became more straightforward. I’ve found that this applies to my code in general, but it was truly highlighted when revisiting the code for my tables.</p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/thicmYTyn9wkhNyg8W9ry/408eb0f3922f48401cb05c704e0346b9/2014-04-16-name-sort.png?fm=webp&w=425&q=80'>
            <img
              alt='2014-04-16-name-sort'
              src='https://images.ctfassets.net/zi79s2th73f3/thicmYTyn9wkhNyg8W9ry/408eb0f3922f48401cb05c704e0346b9/2014-04-16-name-sort.png?w=425&q=80'
              width='212.5'
              height='125.5'
              style='width: 100%; max-width: 212.5px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>Table component built with Vue.js circa 2014</p>
          </figcaption>
          </figure><p>The <a href='/blog/building-the-table-with-vue-js'>first table</a> I built for Cushion was actually written with Vue.js, but it never made it to production because I <a href='/blog/switching-to-angularjs'>switched to Angular</a> shortly after. Vue.js was brand new at the time and not battle-tested enough for me to have confidence in it yet. As a funny aside, I know for a fact that the journal post about this table was written a long time ago because its images weren’t even retina yet! </p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?fm=webp&w=960&w=1332&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?w=960&w=1332&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?fm=webp&w=1280&w=1332&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?w=1280&w=1332&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?fm=webp&w=1332&q=80'>
            <img
              alt='Angular directive table'
              src='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?w=1332&q=80'
              width='666'
              height='404.05309734513276'
              style='width: 100%; max-width: 666px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>Table built with Angular and string concatenation circa 2014</p>
          </figcaption>
          </figure><p>The first table <em>that made it to production</em> is hilariously a single Angular file that takes a config object and renders the HTML by plus-equalling a string all the way down… Yeah, you heard right. I assume this was for performance reasons (<em>because Angular</em>), but it’s also complete madness. At the same time, the code is well-organized and pretty easy to grasp, but here’s the best way to put it: I wouldn’t want to see this code on the first day of my new job.</p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?fm=webp&w=960&w=1332&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?w=960&w=1332&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?fm=webp&w=1280&w=1332&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?w=1280&w=1332&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?fm=webp&w=1332&q=80'>
            <img
              alt='Angular coffeescript table'
              src='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?w=1332&q=80'
              width='666'
              height='431.02153846153846'
              style='width: 100%; max-width: 666px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>Table built with Angular and lodash templating circa 2016</p>
          </figcaption>
          </figure><p>The next iteration of the table was a CoffeeScript class that gets extended and uses getter methods for everything. Again, too clever and requires a sherpa to find your way around the code, but at least I used templates this time (with <a href="https://lodash.com">lodash</a>) instead of manually concatenating a string. I did notice, however, that I completely abandoned semantics with this version by ditching <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;table&gt;</code> tags in favor of living in <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;div&gt;</code> city for easier styling. Little did I know you can have the best of both worlds with semantic tags, easy styling, and proper accessibility, but I didn’t learn that until years later.</p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?fm=webp&w=960&w=1332&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?w=960&w=1332&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?fm=webp&w=1280&w=1332&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?w=1280&w=1332&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?fm=webp&w=1332&q=80'>
            <img
              alt='Vue 2 table'
              src='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?w=1332&q=80'
              width='666'
              height='313.70467289719625'
              style='width: 100%; max-width: 666px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>Table built with Vue 2 Options API circa 2017</p>
          </figcaption>
          </figure><p>The most recent iteration of the table (albeit five years ago) uses Vue 2’s Options API and <a href="https://v2.vuejs.org/v2/guide/mixins.html">Mixins</a>. This approach had a lot going for it because of Vue’s templating and reactivity, which was a <em>huge</em> leap forward because I no longer needed to manually re-render the HTML with every change. I still relied heavily on config objects, but they were now <a href="https://v2.vuejs.org/v2/guide/computed.html">computed properties</a>, so I also didn’t need to directly call getter methods like before—I simply looped through the data and referenced the properties. </p><p>My overall gripe with this version was the use of mixins. I never loved mixins in Vue because it’s never clear what a mixin is doing under the hood unless you dig through the code. This makes troubleshooting and searching for specific references really difficult—especially when a mixin creates props or reactive data. This is also why I’m head-over-heels for Vue’s <a href="https://vuejs.org/guide/extras/composition-api-faq.html">composition API</a> because, combined with <a href="https://vuejs.org/guide/reusability/composables">composables</a> (aka “hooks”), everything you need to reference is exposed, and it feels like writing straight vanilla JavaScript—nothing is buried and nothing feels “magical” (in the way that you can’t understand or troubleshoot it).</p><p>Ok, phew. This brings us to today. I’m building a table for the nth time, but the scenario is different:</p><ul><li><p>I have 10 more years of experience under my belt</p></li><li><p>I now fully test the frontend, which helps to weed out “clever” code</p></li><li><p>I’m committed to semantic HTML and accessibility, so the markup will be “proper”</p></li><li><p>I’m using Vue 3 with the Composition API, which is the closest Vue’s syntax has gotten to clean ol’ vanilla JS</p></li></ul><p>I haven’t <em>finished</em> building the table, so this post is somewhat premature, but there was plenty of “history” to share before writing about the latest iteration. That said, I’m making great progress already. </p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=960&w=1332&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=960&w=1332&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=1280&w=1332&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=1280&w=1332&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=1332&q=80'>
            <img
              alt='Vue 3 table'
              src='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=1332&q=80'
              width='666'
              height='379.3157360406091'
              style='width: 100%; max-width: 666px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>Table built with Vue 3 Composition API circa 2023</p>
          </figcaption>
          </figure><p>I mentioned before about using <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;table&gt;</code> tags with proper accessibility <em>and</em> flexible styling. Here’s what I meant. If we keep the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;table&gt;</code> as <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>display: table</code>, styling is affected by the default table styles, which make certain styles impossible, like putting a bottom border on a <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;tr&gt;</code>. If, however, you set the table’s display to <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>grid</code> or <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>flex</code>, you can gain the styling flexibility, but you also <em>lose</em> accessibility—the table will no longer have its “table” role (in Safari, at least. It looks like other browsers no longer drop the role as of the time of this writing). Fortunately, it’s not difficult at all to reinstate accessibility roles for the table by simply setting the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>role</code> attribute on each element. Every single table element has its own role value, and since I’m writing my own components for these elements, it couldn’t be easier to set these roles once and have them applied everywhere. I could actually write an entire post about this, but… it’s getting late, so I’m going to stop here.</p><p>While I do feel great so far about how I’m building this table component, I know I’m still in the <em>easy</em> stage—I have yet to implement any interactions, including sorting, drag &amp; drop, or context menus, so there’s still plenty to work through. That said, I feel like I’m in the best position I’ve ever been in to write the most future-proof version of this table yet. And I know—it’s just a table. But when you’ve iterated on a single component over the course of a decade, recreating it every few years with the latest tech becomes a fun exercise, like building a to-do app with different frameworks. Or maybe I’m strange for thinking this is fun… It <em>is</em> fun, though, …right?</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Building%20a%20table%20for%20the%20nth%20time">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A return to tabbed navigation]]></title>
            <link>https://destroytoday.com/blog/a-return-to-tabbed-navigation</link>
            <guid>https://destroytoday.com/blog/a-return-to-tabbed-navigation</guid>
            <pubDate>Wed, 27 Dec 2023 14:27:00 GMT</pubDate>
            <description><![CDATA[With a focus on navigation, I return to the early days when Cushion had a simple tabbed nav.]]></description>
            <content:encoded><![CDATA[<p>Once an app achieves even the slightest bit of complexity, it’s time to think about navigation. Not only does navigation help the user move throughout the app, but if you pay close attention, it also becomes a complexity meter for your app. As the app starts to grow in scope, if you find yourself rethinking the navigation solely to accommodate new sections that wouldn’t otherwise fit in the current navigation, that’s a warning sign. Unfortunately with <a href="https://cushionapp.com">Cushion</a>, I flew too close to the sun, ignored the signs, and found myself with a sidebar navigation—one of the clearest indications that an app is trying to do too much.</p><p>I still remember <a href="https://twitter.com/destroytoday/status/785128886430470144">this ironic tweet of mine</a> and how I fell right into this hole:</p><picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?fm=webp&w=960&'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?w=960&'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?fm=webp&w=1280&'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?w=1280&'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?fm=webp&'>
            <img
              alt='Sidebar nav tweet'
              src='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?'
              width='660'
              height='236'
              style='width: 100%; max-width: 660px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture><p>Now that I’m <a href='/blog/imagining-cushion-circa-2016-in-2024'>rethinking everything</a>, I’m eager to go back to basics with a navigation that limits—or rather <em>contains</em>—the scope. The original tab-based navigation for Cushion actually fits the bill pretty well, as it’s confined to the max-width of the app, but it’s also influenced by the tab count. Three tabs look great. Four tabs is pushing it. Five tabs?—time for a long look in the mirror. That’s probably an extreme take, but this time around, I’m really aiming for laser focus.</p><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?fm=webp&w=1440&q=80'>
            <img
              alt='Nav rethink (grey)'
              src='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?w=1440&q=80'
              width='720'
              height='130.9090909090909'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>For Cushion, if I want tabs for “Clients”, “Projects”, and “Invoices”, then a few admin tabs, like “Account”, “Preferences”, and “Billing”, I can simply put those in separate views, like I did with the original Cushion—one view for actually using the app and the other for admin.  Clicking the user’s name in the header takes them to the admin view and swaps out the tabs for the admin tabs. Then clicking Cushion’s logo follows the standard of returning “home” to the main view of the app, where the feature-focused tabs are shown.</p><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?fm=webp&w=1440&q=80'>
            <img
              alt='Nav rethink (admin)'
              src='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?w=1440&q=80'
              width='720'
              height='130.9090909090909'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>Going back to tab count for a moment, while I <em>do</em> think three is the sweet spot for feature-focused tabs, I actually don’t have a problem with a higher tab count for admin views. The user doesn’t spend nearly as much time there as they do in the actual app, so I’m not bothered by a few extra tabs for clarity—as long as the user can get to where they need to go in two clicks or fewer, I’m happy.</p><p>Since I’m looking to return to simpler times while also modernizing and improving Cushion along the way, one consideration I’m thinking about is personalization through color. The existing Cushion is pretty grey, but the original Cushion was <em>very</em> grey. That said, once the user starts filling up the graphs with client colors, the grey fades into the background as a canvas. In any case, I’m toying with the idea of introducing a personalized “theme” color that could be unique between users. Strangely, Cushion has always had a setting for the user’s favorite color, but I never ended up wiring that to anything. While I’m currently focused on the navigation, this might be a good time to tie the theme color to the UI.</p><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?fm=webp&w=1440&q=80'>
            <img
              alt='Nav rethink (yellow)'
              src='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?w=1440&q=80'
              width='720'
              height='130.9090909090909'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?fm=webp&w=1440&q=80'>
            <img
              alt='Nav rethink (purple)'
              src='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?w=1440&q=80'
              width='720'
              height='130.9090909090909'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?fm=webp&w=1440&q=80'>
            <img
              alt='Nav rethink (tan)'
              src='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?w=1440&q=80'
              width='720'
              height='130.9090909090909'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>By using the background of the top “header” area for the theme color, I could introduce personalization while still maintaining a neutral canvas for the graph below it. The tabs would then need to embrace black or white alpha colors instead of using a greyscale, and I’d need to add logic for determining whether to use black or white text to maintain strong contrast, but that’s easy enough. The real question would be whether I provide a preset color palette to ensure the UI looks good or allow custom colors. The answer to this might be the “why not both?” meme.</p><p>I’m off to a good start with this navigation already, and I feel good about its longevity. I <em>am</em> anticipating several edge cases, like clicking into an invoice and how to handle that with tabs, but I’m not worried—there are plenty of best practices to lead the way and I’m not looking to reinvent the wheel. </p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20A%20return%20to%20tabbed%20navigation">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[End-to-end testing with Playwright]]></title>
            <link>https://destroytoday.com/blog/end-to-end-testing-with-playwright</link>
            <guid>https://destroytoday.com/blog/end-to-end-testing-with-playwright</guid>
            <pubDate>Thu, 21 Dec 2023 15:16:00 GMT</pubDate>
            <description><![CDATA[With the stack picked out, I set up end-to-end testing from the start, so I can get in the habit of testing user flows without any excuses.]]></description>
            <content:encoded><![CDATA[<p>In addition to <a href='/blog/vercel-supabase-vue-and-vite'>the new stack</a> for the <a href="https://cushionapp.com">Cushion</a> rewrite, I also want to touch on the testing harness. Back when I started Cushion, I was big into testing—and I’m still proud that the Ruby backend has rock solid test coverage—but for whatever reason, the frontend doesn’t have much test coverage beyond the client-facing invoice page. I’m not sure why, considering I fully tested the <a href="https://teuxdeux.com">TeuxDeux</a> frontend when I worked on it before Cushion, but maybe I was too excited for rapid progress. In any case, testing the frontend is so easy now that there’s not much of an excuse not to.</p><p>Personally, I’ve never had a problem thoroughly unit testing components, but when it comes to code that interacts with the API—where you <em>should</em> rely on end-to-end testing—I always feel the weight of the initial setup and tell myself I’ll do it later, but never do. With the existing Cushion, if I wanted to set up e2e testing at this point, I’d need to find a way to run all the servers within GitHub Actions, which is a daunting task (for me). Alternatively, I could do what plenty of folks probably do, which is to run their e2e tests against the staging server. Then, you just need to make sure you clean up after each run.</p><p>Since I’m starting from scratch—and determined to start on the right foot—I can keep e2e testing in mind from the get-go. Of course, I’ll also unit test components, but the coverage from e2e tests will be invaluable for sanity-checking the actual user flows. It’s far too easy for unit tests to pass while an unwritten e2e test would be failing—especially if you resort to unit testing API interactions with spies, but your API changes and your spies don’t. (I can hear a few of you nervously laughing right now…)</p><p>By using <a href="https://supabase.com/">Supabase</a> as my pseudo-backend that provides a direct client-side connection to the database, it’s much easier to set up e2e testing from the beginning (as long as their API is available). They do also have a <a href="https://supabase.com/docs/guides/self-hosting">self-hosted</a> version if I wanted to go full neckbeard, but hosted will suffice for me.</p><p>As for the test harness, I’m using <a href="https://playwright.dev/">Playwright</a>, which has emerged as the go-to option for e2e testing. There are others, like <a href="https://www.cypress.io/">Cypress</a>, but I feel like Playwright speaks more my language and would be familiar to anyone who has used <a href="https://jestjs.io/">Jest</a> or <a href="https://vitest.dev/">Vitest</a>. For those who haven’t written <em>any</em> e2e tests before, it’s actually fun. You simply write line-by-line what the user should do, like “go to this page”, “find this input”, “fill it in”, “click this button”. Obviously this is pseudocode, but the actual API reads just as easily. Playwright also has a <a href="https://playwright.dev/docs/codegen">codegen feature</a> where you can record yourself clicking through the browser and it’ll write the test for you. Seriously, with features like this, you have no excuse.</p><p>So far, I’ve been able to fully test the auth flow as well as an initial setup form. These two cases have given me enough experience in this new stack to know how to quickly handle most situations. Along the way, I’m even writing helper functions, like one-line sign in, etc. The only hiccup I’ve come across is how best to reset the database, so I can do a clean run each time. I’m sure you can run a query on the database directly before or after the tests run, but instead, I saw this as an opportunity to implement a feature folks have asked for in the past—resetting an account.</p><p>For Cushion, users often become subscribers for a couple years, then they might take a full-time job and no longer need to freelance or use the app. Several years later, they might return, but want to start fresh with an empty account instead of returning to their old data. This is where a reset feature comes in handy.</p><p>I do realize that this is a dangerous feature and resetting your account is not something to take likely—especially for the users who have been with Cushion since the beginning. With those folks, we’re talking about almost 10 years of data. Because of this, resetting your account will certainly come with a safeguard in the form of a dramatic “Are you sure?” prompt that makes you type exactly what this feature does. I imagine I’ll eventually opt for a database query to clear the database to save time on click-throughs, but for now, this works and unblocks me.</p><p>Next up, I’ll start to dig into the actual app by setting up all the models as I need them, and make something usable. I’m excited!</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20End-to-end%20testing%20with%20Playwright">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Vercel, Supabase, Vue, and Vite]]></title>
            <link>https://destroytoday.com/blog/vercel-supabase-vue-and-vite</link>
            <guid>https://destroytoday.com/blog/vercel-supabase-vue-and-vite</guid>
            <pubDate>Wed, 20 Dec 2023 13:59:00 GMT</pubDate>
            <description><![CDATA[As I rethink Cushion in the modern age, I detail my choices for platform, stack, and database.]]></description>
            <content:encoded><![CDATA[<p>Now that the <a href='/blog/imagining-cushion-circa-2016-in-2024'>last post</a> is behind me, I want to start getting into the details of a 2016 version of <a href="https://cushionapp.com">Cushion</a> built in 2024. My original plan was to only refresh the frontend and then rely on the existing API to save time, presumably. That would’ve worked fine, but I also feel like it would’ve still restricted my choices and influenced how I develop this new version. The priority <em>is</em> refreshing the frontend, but that doesn’t mean there isn’t an equally long to-do list of improvements and simplifications I want to make on the backend as well. If I’m “stuck” with a backend that I started writing 10 years ago, I’ll miss out on all the innovations that have happened since then—especially in this new world of frontend-friendly backends.</p><p>The existing Cushion is hosted mostly on <a href="https://heroku.com">Heroku</a>—a once-exciting platform that now seems stuck in 2010, when it was acquired by Salesforce. There’s still a lot to love about Heroku, but I’m looking elsewhere this time, in the direction of <a href="https://vercel.com">Vercel</a>. This is a no-brainer for me and not much of a risk because I’ve been using Vercel for pretty much every website I’ve hosted over the past several years. The speed and ease in which you can go from repo to hosted website—with automatic deployments between dev, preview, and prod—is the way the web should be. That said, this will be my first time hosting an actual app with real users on Vercel—beyond a marketing site or a hosted form—so I can’t say I’ve had to troubleshoot something going wrong on Vercel. At the same time, I plan to greatly simplify the backend because it honestly doesn’t need much beyond what can be done in the database. And that’s a perfect segue into our next topic—the database. </p><p>Since the start, Cushion has used <a href="https://www.postgresql.org/">Postgres</a> as its main database, and I have no regrets with that choice—I’ve always been a relational guy and Postgres only seems to get more and more feature-rich with each and every upgrade. The world <em>around</em> Postgres has gotten pretty interesting over the years, too, with database providers making the new Postgres features more turnkey. This is why I’ve landed on <a href="https://supabase.com/">Supabase</a> as my database provider. I have no problem coding a backend, but as soon as I saw that you can use a Postgres database directly from a static frontend <em>and</em> it handles authentication?—I was sold. On top of that, Supabase takes care of the hairiness of Postgres’s realtime features, so you can subscribe to messages, presence, and database changes with a simple client-side listener—sold again. These features unlock so much potential for a modern Cushion, and Supabase makes the features so easily accessible that I can casually experiment with a random idea before feeling like I need to commit.</p><p>Now that I’ve touched on the backend—or rather the lack of need for a traditional backend—let’s look at the frontend. Anyone who knows me knows my preference for <a href="https://vuejs.org/">Vue</a>, so this is another no-brainer. Oddly enough, though, the majority of Cushion’s frontend actually isn’t Vue. When I first started working on Cushion back in 2014, Vue was only starting to make a name for itself. <a href='/blog/building-the-table-with-vue-js'>I gave it a shot</a> for a few trips around the block, and while it was fun, exciting and new, Vue didn’t have the maturity I was looking for, so I friend-zoned it. I ended up playing it safe with <a href="https://angular.io/">Angular</a> (v1.2), which shared some similarities with Vue at the time, but was also structured like the MVCS (model, view, controller, service) architectures I knew and loved in the <a href="https://en.wikipedia.org/wiki/ActionScript">AS3</a> days. While I found that appealing at the time, I can assure you my interests have since changed. A few years into using Angular, I fell out of love because of its complexity and sluggishness. Fortunately for me, Vue had grown up a lot in those years, seemingly waiting out my relationship with Angular. We’ve been together ever since.</p><p>While most of the Vue code that lives in the existing Cushion is <a href="https://v2.vuejs.org/">Vue 2</a> with the Options API, I’m using Vue 3 with the <a href="https://vuejs.org/guide/extras/composition-api-faq.html">Composition API</a>, which is so much more TypeScript-friendly and reads like vanilla JS. When using <a href="https://vuejs.org/api/sfc-script-setup.html"><code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;script setup&gt;</code></a>, any const or function is exposed to the template automatically, so it’s immediately familiar to any straight-JS dev and even more inviting than it was before. I could wax poetic about my love of Vue, but I’ll stop right here and save you all.</p><p>When we talk about frontend, we unfortunately still need to talk about bundling. Back in 2014, the initial build scripts for Cushion’s frontend used <a href="https://gulpjs.com/">Gulp</a>—remember Gulp?? Gulp existed well before we had hot module replacement, but we did still had <a href="https://chromewebstore.google.com/detail/livereload/jnihajbhpnppcggbcgedagnkighmdlei?pli=1">LiveReload</a> to refresh the entire page on save. For several years, I tried to migrate to <a href="https://webpack.js.org/">Webpack</a>, but gave up each time—those who have also tried know what I’ve been through. Then, in 2019, I successfully switched to <a href="https://cli.vuejs.org/">Vue CLI</a>, which does use Webpack under the hood, but at least provides a wrapper around it to make it less painful. Still, with an Angular frontend that has Vue embedded throughout, there was no way I could get HMR to cooperate.</p><p>These days, Vue CLI has been replaced with <a href="https://vitejs.dev/">Vite</a>—an incredibly quick, easy-to-setup, and extensible bundler and dev server from the folks behind Vue. I’ve been using Vite ever since building ProtoPen, my prototyping environment when I worked at <a href="https://stripe.com/">Stripe</a>. Like everything Vue-related, it speaks my language, and I just <em>get it</em> from the start. I’ve tried other frameworks over the years, like <a href="https://nuxt.com/">Nuxt</a>, but the magic that happens behind the scenes always ends up causing problems down the road, and I always find myself needing to troubleshoot magic. To save myself the future headache, I’m keeping it simple, and sticking with straight Vite.</p><p>That’s all for the initial setup. We’ll see if these choices stand the test of time. </p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Vercel%2C%20Supabase%2C%20Vue%2C%20and%20Vite">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Imagining Cushion circa 2016 in 2024]]></title>
            <link>https://destroytoday.com/blog/imagining-cushion-circa-2016-in-2024</link>
            <guid>https://destroytoday.com/blog/imagining-cushion-circa-2016-in-2024</guid>
            <pubDate>Mon, 18 Dec 2023 13:45:00 GMT</pubDate>
            <description><![CDATA[With Cushion’s 10-year anniversary approaching, a theory leads to an attempt at a modern upgrade, but with 2016’s feature set.]]></description>
            <content:encoded><![CDATA[<p>It’s hard to believe, but <a href="https://cushionapp.com/">Cushion</a> turns 10 years old in a few months. Even in real life, 10 years is a long time, but in software, 10 years is an eternity. 10 years ago, <a href="https://vercel.com">Vercel</a> and <a href="https://openai.com">OpenAI</a> didn’t exist, <a href="https://react.dev/">React</a> and <a href="https://vuejs.org/">Vue</a> were brand new frameworks, and we were still writing <a href="https://coffeescript.org/">CoffeeScript</a> because <a href="http://es6-features.org/">ES6</a> didn’t exist yet. Even so, after all these years, I’m proud to say that Cushion still works, still makes an honest income, and I’m still passionate about it. </p><p>The past fews years leading up to this 10-year milestone, however, have been full of existential thoughts. When I look at where Cushion is now (and honestly where it’s been for the last ~5 years), I feel a sense of nostalgia and longing for the years before it—when Cushion was only me and only a side project. (Cushion currently <em>is</em> only me and only a side project, but I’ll explain what I mean…)</p><p>It’s easy to forget, but for a couple of years in the middle, Cushion was a venture-backed startup. In mid-2016, I made the decision to take Cushion to the next level, from a bootstrapped side project to a startup with a team and funding. That change significantly altered the trajectory and made Cushion more than only me and more than only a side project. I truly believed at the time that Cushion could be the <em>only</em> tool for freelancers, and I <em>thought</em> that’s what I wanted. While the bootstrapping years were as wholesome as it gets—with me casually whittling away at my precious object and consistently writing about it—the startup years were a nonstop sprint of aiming for the fences, under the gun of a dwindling runway, in a startup world where I always felt like an imposter.</p><p>In hindsight, I’m glad we tried it, but ever since those years, I can’t shake this aching feeling that Cushion is “off-course”. Instead of embracing what worked really well up until that point and doubling down on <em>those</em> features, we were always in search of the “network effect” that would grow Cushion exponentially. We spent months working behind the curtain on big features that would never leave private beta, while the rest of the app remained stagnant. I never felt good about neglecting the features that people actually used, and I felt even worse when I needed to cut our losses with the big features—erasing months of progress in the wrong direction. </p><p>Ever since the startup experience, I’ve had a theory brewing inside me—or rather eating at me. It feels obvious now, but it didn’t at the time: if I would’ve stopped expanding Cushion’s scope at the 2-year mark—when Cushion was thriving—and instead focused on growing the user base with the features that really worked well at that time, Cushion would’ve grown more while also being <em>easier</em> to grow, maintain, and improve. This makes sense now, but it didn’t cross my mind at the time—I’m a builder at heart.</p><p>Now that I’m on holiday break for a few weeks, I’m ready to test this theory. When I look through my <a href="https://cushionapp.com/blog/categories/journal">journal posts</a> and <a href="https://cushionapp.com/blog/categories/changelog">changelog entries</a>, that 2-year mark puts us around early 2016—when Cushion was truly in its prime. The app was simple, focused, and easy to market. This is my goal.</p><p>In combination with this theory, I also want to introduce another thought: what would Cushion look like if it were built with a modern stack? Most of Cushion’s inner workings are at least seven years old, including a <a href="https://www.heroku.com/">Heroku</a> infrastructure, <a href="https://www.ruby-lang.org/en/">Ruby</a> backend, and a frontend potpourri of jQuery, CoffeeScript, <a href="https://angular.io/">Angular</a>, <em>and</em> Vue (from when <a href='/blog/how-to-embed-vue-js-and-vuex-inside-an-angularjs-app-wait-what'>I tried</a> to incrementally migrate to Vue by embedding it within Angular…) After living with this codebase for years, I’m certain that a modern upgrade of Cushion’s stack would work wonders for consistent improvements—especially when what used to take weeks could now be done in a day or two.</p><p>A modern upgrade also brings another important factor—excitement. Legacy codebases can be draining, but one way to fight that feeling is to build a dev environment you’re excited to work in. 10 years ago, believe it or not, I was excited to use Angular and <a href="https://webpack.js.org/">Webpack</a>. Now, not so much. These days, I’m all-in with Vue and <a href="https://vitejs.dev/">Vite</a>, but who knows about 10 years from today. Regardless, Cushion is ready for a fresh coat of paint <em>and</em> a fresh new engine.</p><hr/><p>I could’ve written this post a week earlier with an outline of my plan, but I wanted to hold off until I actually started working on it—and I have. After a day, I already have the web app deployed and running with user models and authentication—including the addition of Google Auth, which would’ve been a headache to implement in the existing codebase. I’m really looking forward to seeing how far I can run with this, while at the same time only considering it a test of a hypothesis. Nothing’s set in stone, but this is what side projects are made for—trying things out and having fun.</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Imagining%20Cushion%20circa%202016%20in%202024">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A weekend marathon of an infrastructure upgrade]]></title>
            <link>https://destroytoday.com/blog/a-weekend-marathon-of-an-infrastructure-upgrade</link>
            <guid>https://destroytoday.com/blog/a-weekend-marathon-of-an-infrastructure-upgrade</guid>
            <pubDate>Fri, 28 Apr 2023 12:38:00 GMT</pubDate>
            <description><![CDATA[I spent a weekend upgrading Cushion’s Heroku stack and Ruby version, which sent me down a path of fixing hundreds of failing tests and removing a feature.]]></description>
            <content:encoded><![CDATA[<p>Earlier this month, I received an email from Heroku that my “stack” was reaching its EOL (end-of-life) and I needed to upgrade to the latest stack. These emails typically catch me off guard because over the years I’ve actually lost my appetite for fancy new things—I’m much more attracted to tech that’s boring, battle-tested, and does the job. That said, this EOL was more about Heroku no longer supporting an older Ubuntu OS and no longer providing security updates for it, so this wasn’t fancy at all—it was more of a necessity.</p><picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/6eOaKQ24oXcRLVSYxarNEG/0a13ee359ce9163369d2cfabaaae80c6/heroku-22-failing-tests.png?fm=webp&w=960&'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/6eOaKQ24oXcRLVSYxarNEG/0a13ee359ce9163369d2cfabaaae80c6/heroku-22-failing-tests.png?w=960&'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/6eOaKQ24oXcRLVSYxarNEG/0a13ee359ce9163369d2cfabaaae80c6/heroku-22-failing-tests.png?fm=webp&w=1280&'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/6eOaKQ24oXcRLVSYxarNEG/0a13ee359ce9163369d2cfabaaae80c6/heroku-22-failing-tests.png?w=1280&'
                    media='(max-width: 640px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/6eOaKQ24oXcRLVSYxarNEG/0a13ee359ce9163369d2cfabaaae80c6/heroku-22-failing-tests.png?fm=webp&w=1920&'
                      media='(max-width: 960px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/6eOaKQ24oXcRLVSYxarNEG/0a13ee359ce9163369d2cfabaaae80c6/heroku-22-failing-tests.png?w=1920&'
                    media='(max-width: 960px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/6eOaKQ24oXcRLVSYxarNEG/0a13ee359ce9163369d2cfabaaae80c6/heroku-22-failing-tests.png?fm=webp&'>
            <img
              alt='heroku-22-failing-tests'
              src='https://images.ctfassets.net/zi79s2th73f3/6eOaKQ24oXcRLVSYxarNEG/0a13ee359ce9163369d2cfabaaae80c6/heroku-22-failing-tests.png?'
              width='999'
              height='613.5'
              style='width: 100%; max-width: 999px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture><p>Making a significant upgrade like this could be nerve-wracking, and it was, despite Cushion’s backend being incredibly well-covered with tests. After running the entire test suite locally, I was reminded that there are in fact ~5,500 tests for Cushion’s backend. This calms my paranoia when I need to make any backend changes, which is incredibly important for an app that’s over nine years old. Even so, with such a significant upgrade that involves a major version bump for both the OS and backend framework, I was still pretty nervous.</p><p>At first, I didn’t even know that Ruby would need a major upgrade as well, but I quickly learned this when my CI complained that Heroku 22 doesn’t support Ruby 2.x. I’d be the old man yelling at clouds about this, but it was fine. I <em>should</em> keep everything up-to-date for security purposes, even if I don’t need the shiny new features. When I bumped the Ruby version, I started to see the real work that needed to be done—217 failing tests. I know the exact number because I kept a log of my progress as I chopped away at it. Even with a number to track, however, I didn’t know the true weight of each failing test. A simple dependency upgrade could fix half of these tests whereas a single failing test could end up taking days to fix. Considering this unknown along with a tight deadline of May 1st, I decided to set up camp at my wife’s artist studio for an entire weekend and get to work. Luckily for me, that weekend was the start of the NBA playoffs.</p><p>After an entire 12-hour day (or four NBA playoff games), I was able to get the number of failing tests from 217 down to 161. This <em>did</em> feel like progress, but not as much as I had hoped. The reality is that this progress wasn’t a straight line. Halfway through the day, I had actually gone <em>up</em> to 228 failing tests. </p><p>With certain fixes, like updating a dependency to support a new syntax, you can sometimes end up with more failing tests because the updated dependency included breaking changes of its own. This means that it’s not always a given that I’d be making forward progress. It’s your classic one step forward, two steps back. I wasn’t deterred, though, because the test suite would always let me know exactly what broke—I never had to guess. After this first day of troubleshooting, I still felt accomplished because in addition to fixing dozens of failing tests, I also found my footing. I was now confidently wearing my backend hat, and I had momentum carrying me forward at a steady pace.</p><p>The next day, after another solid 12-hour day, I got the number of failing tests down to six. I couldn’t believe how much progress I had made on such an open-ended scope. I actually gave myself several weeks of lead time to finish the upgrade in time just in case. This shouldn’t be a surprise to anyone who knows me, but I’m often the person who arrives at the airport so early that I can take the earlier flight (and that’s happened!)</p><p>Fixing the final failing tests actually involved removing a feature. One of my bigger regrets with Cushion early on is that I decided to build custom integrations too soon. For a solo dev, integrations become a prime example of Murphy’s Law because 3rd party services tend to make changes without notice. You’re heads-down working on the new feature when a user points out that a certain integration isn’t working. You take a closer look and the service launched a new API that’s alpha and specifically says that it’s bound to change, but your integration relies on the rock-solid-but-now-deprecated API. It’s such a draining context switch. I should’ve just used a service that manages these connections for me.</p><p>Back to the feature in question—Cushion’s Xero integration. This feature has been a thorn in my side for years because their API requires a specific certificate-based authentication that’s strangely complicated and unlike any service I’ve authenticated with before. In addition, Xero calculates their invoice tax differently than every other service, so even if I imported invoice line items identically to how they are in Xero, there’s a chance the tax and totals are slightly different—not great when you’re dealing with currency. With all of these scars, <em>and</em> the fact that Cushion doesn’t have any active Xero integrations at the moment, I decided to quietly sweep it under the rug. I honestly don’t think anyone will miss it.</p><picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/2vcVXR8inugZZYJg19tiXa/0aab5330d4edb6235da528f31a18ea72/heroku-22-passing-tests.png?fm=webp&w=960&'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/2vcVXR8inugZZYJg19tiXa/0aab5330d4edb6235da528f31a18ea72/heroku-22-passing-tests.png?w=960&'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/2vcVXR8inugZZYJg19tiXa/0aab5330d4edb6235da528f31a18ea72/heroku-22-passing-tests.png?fm=webp&w=1280&'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/2vcVXR8inugZZYJg19tiXa/0aab5330d4edb6235da528f31a18ea72/heroku-22-passing-tests.png?w=1280&'
                    media='(max-width: 640px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/2vcVXR8inugZZYJg19tiXa/0aab5330d4edb6235da528f31a18ea72/heroku-22-passing-tests.png?fm=webp&w=1920&'
                      media='(max-width: 960px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/2vcVXR8inugZZYJg19tiXa/0aab5330d4edb6235da528f31a18ea72/heroku-22-passing-tests.png?w=1920&'
                    media='(max-width: 960px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/2vcVXR8inugZZYJg19tiXa/0aab5330d4edb6235da528f31a18ea72/heroku-22-passing-tests.png?fm=webp&'>
            <img
              alt='heroku-22-passing-tests'
              src='https://images.ctfassets.net/zi79s2th73f3/2vcVXR8inugZZYJg19tiXa/0aab5330d4edb6235da528f31a18ea72/heroku-22-passing-tests.png?'
              width='999'
              height='625.5'
              style='width: 100%; max-width: 999px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture><p>The following weekend, I merged the code, deployed to staging, and did a thorough click-through of Cushion’s staging environment. Everything <em>seemed</em> fine, so I went ahead with the production deploy. I truly didn’t know what to expect because these are often the moments where one unexpected hiccup occurs and melts your innards as you try to quickly troubleshoot the issue. Fortunately, this particular deploy went without a hitch. I was actually unsettled by how uneventful it was, but that’s the point—no news is good news. Over the next few days, I kept an eye on production, but everything was fine. I’m relieved, but also proud. I’m not primarily a backend dev, but when put in the situation, I can wear that hat. It’s also empowering because I know (with an app of Cushion’s size) that there’s nothing I can’t handle—even after all these years.</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20A%20weekend%20marathon%20of%20an%20infrastructure%20upgrade">Reply via email</a></p>]]></content:encoded>
        </item>
    </channel>
</rss>