<?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>Perttu Lähteenlahti</title>
        <link>https://perttu.dev</link>
        <description>Practical guides on React Native, mobile app development, and app monetization by Perttu Lähteenlahti.</description>
        <lastBuildDate>Sun, 10 May 2026 06:13:53 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Perttu Lähteenlahti</title>
            <url>https://perttu.dev/favicon.ico</url>
            <link>https://perttu.dev</link>
        </image>
        <copyright>All rights reserved 2026</copyright>
        <item>
            <title><![CDATA[20 things about 2020]]></title>
            <link>https://perttu.dev/articles/20-things-about-2020</link>
            <guid isPermaLink="false">https://perttu.dev/articles/20-things-about-2020</guid>
            <pubDate>Thu, 31 Dec 2020 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Not really a fan of yearly reviews. I think there are easier ways to brag about your accomplishments than trying to mask them under an annual review. However, if you should ever review a year, this one would be it. So here are 20 things about 2020 from my perspective.</p>
<ol>
<li>
<p><strong>COVID</strong><br>
<!-- -->This first point probably surprises no one. For most, this year has brought massive changes to their lives. For me, though, it didn't change anything. Maybe it's because Finland handled the whole situation relatively well. Or perhaps it is just that life, in general, is a lot easier for a privileged white male, and even things like a global pandemic can't change that. It tells a little bit about my social circles' size when I say that nobody I genuinely know caught the coronavirus this year. Maybe I've just been fortunate (knocks on wood)?</p>
</li>
<li>
<p><strong>Nyxo</strong><br>
<!-- -->This was a challenging year for Nyxo. Last year ended on a positive note, taking part in TechCrunch Disrupt, getting a small business loan approved, and then COVID hit. We lost some clients: all the work we were doing with airlines evaporated instantly, some clients stopped answering emails entirely, and some asked to talk again in the autumn. We applied for Business Finland COVID funding but got almost nothing (later, I found out that companies doing half the revenue we had received over ten times as much funding). My co-founder had just flown to San Francisco to explore that option but was forced to fly back after just two weeks. Eventually, we also had to lay off the people we had hired at the beginning of the year. That was hard.</p>
<p>It remains to be seen what happens next. We've managed to get some of the momentum back, but we are still far from what we had a year ago. I'm going to write a separate post about Nyxo at a later time, and I certainly hope it's not an obituary, but you never know with these bootstrapped startups (you either succeed or you die). If you want to help, trying out the app, giving feedback, and leaving an App Store review is something I would really appreciate.</p>
</li>
<li>
<p><strong>Your boy's got a bachelor's now</strong><br>
<!-- -->Yeah, so I finally have something to show for my studies this year, after managing to submit all the required papers for completing a Bachelor's of Science in cognitive science. I know this will surprise some people because most have probably thought that I have a degree in something already. Thing is, I started studying cognitive science in 2013 and managed to complete almost both master's and bachelor's degrees in two years, but I didn't. I was so caught up in other things, such as building companies, that I didn't even have time to submit my bachelor's thesis to be reviewed despite it being finished. This year I realized that my parents might never witness me graduate, so I decided to put some of my time into completing my studies (I personally don't see them as being that valuable anymore, I might write about why in the future). I still need to finish up my master's thesis, but I hope to get that done in the spring now.</p>
</li>
<li>
<p><strong>Spending more time with my parents</strong><br>
<!-- -->This year I've focused more time on spending time with my parents, which is weird because a few years ago, my only reason for visiting them was to check up on the apartments I own in the city they live in. Not because we were on bad terms or anything, I was just in the wrong place and felt going there as a burden. But this year I've spent more time with them. They're getting older, and eventually, it will be too late to spend that time with them, so better do it now.</p>
</li>
<li>
<p><strong>Loneliest year?</strong><br>
<!-- -->Maybe it's COVID, or maybe it's because I don't have too many friends, I've felt very lonely this year. Depressingly so. It could also be an age-related thing; I'm turning 30 next year. Maybe it's just how it works. Maybe it's because I'm a miserable person to be around. Maybe it's my bad personality and the fact I'm not good with people. Maybe it's Maybelline.</p>
</li>
<li>
<p><strong>Six years of 60 hours a week</strong><br>
<!-- -->Since 2014 when I stopped studying full-time, I've worked an average of 60 hours a week. Sometimes this has meant studying and working full-time at the same time, sometimes it has been working two jobs simultaneously (once even three jobs for a short period), but mostly it's been because I've worked a day job while building a company. This year is no different; however, I'm starting to see that this will no longer work. The cognitive strain seems to be too much. Next year I might dial down on the workload a little and spend more time being bored.</p>
</li>
<li>
<p><strong>A great year for falsely thinking you're great at investing</strong><br>
<!-- -->This year has been tremendously great for my savings and investments. So great that I've started to consider selling all my stocks and even some apartments (I bought two more this year, bringing the total number of managed apartments to four). At the same time, I even noticed how people, in general, seem to be more interested in investing, which is a good thing but also makes me a bit skeptical if we're approaching the top of the roller coaster and things will soon start looking a lot scarier.</p>
</li>
<li>
<p><strong>Reading and writing more</strong><br>
<!-- -->Earlier this year, my goal was to speak at more software development conferences (I did my first ones in 2019 and liked the experience a lot), but COVID ruined that plan, so I decided to write some software articles. That has been a mild success, so I might do more of it in the future.</p>
<p>I also read more this year (about 100 books, 30 more than last year) and plan on spending more time in the future reading. This year, I hit a bit of a slump with reading and had only read four books in June before I again found the interest to continue reading. Let's see if I have something similar next year or if I'm finally able to get to my goal of reading ten books a month 😄</p>
</li>
<li>
<p><strong>Cyberpunk 2077 (and gaming in general)</strong><br>
<!-- -->I haven't been gaming in a long time, but this year I decided to change that and bought the new Xbox Series X, and boy, did it change my evenings. I've been having almost as much fun playing as I did 15 years ago. Cyberpunk 2077 is also great; I don't care what others say. I'm going to post some of my thoughts about the game at some point.</p>
</li>
<li>
<p><strong>VC fatigue</strong><br>
<!-- -->I'm not sure what the reason behind this is, but I'm getting tired of VCs and startups a little. It seems always to be the same people getting funded. It's not as interesting as it used to be, and I don't think anyone's really "making the world a better place." Nyxo was the first actual startup I founded, and it was a great exploration of why the Finnish VC scene is so male and white. This year we didn't really speak with any investors; I guess they lost interest in us or we lost interest in them.</p>
</li>
<li>
<p><strong>Queen's Gambit and chess</strong><br>
<!-- -->Found a new hobby this year: chess. All thanks to Queen's Gambit (I'm guessing a lot of people can say the same). Before the show, I had never played in my life. Now I'm both playing and reading about strategy.</p>
</li>
<li>
<p><strong>Living those teenage dreams</strong><br>
<!-- -->When I was a teenager and had just gotten my driver's license, I used to envy other people who were driving new cars (or even had their own vehicles). The only car I had access to was my father's truck. But this year my dad and I decided to buy a new car together (for him, I don't particularly appreciate driving)—an all-electric Volkswagen e-Golf. The teenage Perttu is very happy to finally have access to a car from this decade (my dad is also pleased, which is nice).</p>
</li>
<li>
<p><strong>Pretty fucking good at React Native</strong><br>
<!-- -->I've coded a lot this year. I've built new apps, large component libraries, refactored some of my old projects, and built some truly novel stuff as well (for example, a cross-platform renderer for mobile and web using React Native, as well as a few App Clips by cramming React Native inside that tiny space). I applied for a few jobs out of curiosity, and it turns out I'm pretty fucking good at what I do. For me, that kind of feedback is a big thing because three years ago, I still considered myself mainly a designer who does code, but now it's the other way around.</p>
</li>
<li>
<p><strong>Eames lounge chair</strong><br>
<!-- -->This is going to sound shallow and materialistic, but one of the highlights of this year for me was buying an Eames lounge chair. It's the most beautiful thing I've ever owned, and I've found myself spending more time at home to sit in it. I've desired this chair for five years now and finally decided to get it after all this time. I can die (happy) now.</p>
</li>
<li>
<p><strong>Losing all interest in moving to the USA</strong><br>
<!-- -->A year ago, if someone had asked where I want to be in 2 years, I would have said, "living and working in the USA." But this year changed my mind on this matter. Reading about all the stuff happening there—poor handling of COVID, racism, white supremacy, the healthcare clusterfuck, Republicans (even from an outsider's perspective, Mitch McConnell seems like a pawn of Satan)—has chipped away all my interest. I don't think Biden's election will change this in the short term either, so it could be that one of my biggest dreams just died this year.</p>
</li>
<li>
<p><strong>Helping others</strong><br>
<!-- -->I've tried to spend more time helping others this year. I should probably do more in this area, but offering help doesn't come easily to someone who is afraid to ask for help themselves. So I'm saying now, if you need help with anything, no matter if it's work-related or not, I want to help. Just ask, I will most likely say yes.</p>
</li>
<li>
<p><strong>Lottery</strong><br>
<!-- -->This year I noticed a very weird behavior which isn't like me at all: I've put around 30 euros into taking part in the weekly lottery. For those who don't know, one of my family members suffers from gambling addiction, and it's also the reason I stay away from all forms of luck-based games that involve money. I've also studied a lot of statistics, so I should have a pretty good understanding of the minuscule odds. Despite this, there have been times this year I've thought that winning a million euros from a lottery would certainly help. And what is so bad about a small lottery ticket? And even though one of my friends says that the lottery is essentially about spending a few euros to dream, I don't think I should continue doing it. I'd rather lose my money the old-fashioned way: paying taxes.</p>
</li>
<li>
<p><strong>Year of inactive lifestyle</strong><br>
<!-- -->There's one word that well describes the change from the previous year: sedentary. I've been so inactive this year that it's almost embarrassing. Although the situation is what it is, this isn't the way to go forward. I'm not much for New Year's resolutions, but if I had to make one, it would be to focus more on exercise and stuff. Maybe if I work less I can fit more exercise into my week?</p>
</li>
<li>
<p><strong>What happened to summer?</strong><br>
<!-- -->Not sure if I'm the only one who felt that there was no summer? I mean, it was warm and all, but somehow it felt like the summer lasted for three weeks and then it was back to rain and darkness.</p>
</li>
<li>
<p><strong>Not the worst year, not the best year</strong><br>
<!-- -->Looking at everything going on around the world, I must say I'm in an incredibly privileged position. Yet, I feel like I should have accomplished more this year.</p>
</li>
</ol>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Adding custom options to React Native developer menu]]></title>
            <link>https://perttu.dev/articles/adding-custom-options-to-react-native-developer-menu</link>
            <guid isPermaLink="false">https://perttu.dev/articles/adding-custom-options-to-react-native-developer-menu</guid>
            <pubDate>Sat, 02 Dec 2023 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>TLDR: You can customize the Developer Menu in React Native with your custom actions. If you're already familiar with the Developer Menu, <a href="#customizing-the-developer-menu">click here to jump to section about customizing the menu</a></p>
<h2 id="what-is-the-developer-menu">What is The Developer Menu</h2>
<p>When running a development version of a React Native app, you can access a special Developer Menu by either shaking your device (if you're running the app on the device instead of simulator) or by using the following keyboard shortcuts:</p>
<ul>
<li>iOS <kbd class="font-regular rounded-lg border border-gray-200 bg-gray-100 px-1.5 py-1 text-xs text-gray-800 dark:border-gray-500 dark:bg-gray-600 dark:text-gray-50">Cmd ⌘</kbd> + <kbd class="font-regular rounded-lg border border-gray-200 bg-gray-100 px-1.5 py-1 text-xs text-gray-800 dark:border-gray-500 dark:bg-gray-600 dark:text-gray-50">D</kbd></li>
<li>Android <kbd class="font-regular rounded-lg border border-gray-200 bg-gray-100 px-1.5 py-1 text-xs text-gray-800 dark:border-gray-500 dark:bg-gray-600 dark:text-gray-50">Cmd ⌘</kbd> + <kbd class="font-regular rounded-lg border border-gray-200 bg-gray-100 px-1.5 py-1 text-xs text-gray-800 dark:border-gray-500 dark:bg-gray-600 dark:text-gray-50">M</kbd></li>
</ul>
<p>Doing either of these commands or shaking the device (by device I mean your phone, don't shake your laptop) should reveal menu that looks like this:</p>
<img alt="React Native Developer menu" loading="lazy" width="1179" height="2556" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdeveloper-menu.ded4b95f.png&amp;w=1200&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdeveloper-menu.ded4b95f.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdeveloper-menu.ded4b95f.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>Here's a what all of these items in the list do</p>
<ol>
<li>
<p><strong>Reload</strong><br>
<!-- -->Refreshes the application by loading the latest JavaScript code bundled by the Metro Bundler.</p>
</li>
<li>
<p><strong>Open Debugger</strong> <br>
<!-- -->Opens a new browser tab for debugging JavaScript code using Chrome's developer tools.</p>
</li>
<li>
<p><strong>Open React DevTools</strong><br>
<!-- -->Opens React DevTools for debugging the app's React component hierarchy.</p>
</li>
<li>
<p><strong>Show Inspector</strong><br>
<!-- -->Opens an inspection tool for examining and modifying the layout and style of React Native components.</p>
</li>
<li>
<p><strong>Show Inspector</strong><br>
<!-- -->Allows inspection of UI elements by tapping on them to see detailed information.</p>
</li>
<li>
<p><strong>Disable Fast Refresh</strong><br>
<!-- -->Disables the Fast Refresh feature, which automatically reloads the app upon saving code changes.</p>
</li>
<li>
<p><strong>Configure Bundler</strong><br>
<!-- -->Provides options to configure the Metro Bundler used in the React Native app.</p>
</li>
<li>
<p><strong>Show Perf Monitor</strong><br>
<!-- -->Displays a performance monitor with real-time stats like FPS and CPU usage.</p>
</li>
</ol>
<h2 id="customizing-the-developer-menu">Customizing The Developer Menu</h2>
<p>You can also add your own items to the Developer menu. React Native comes bundled with The DevSettings module which exposes methods for customizing the options in the menu.</p>
<p>Import <code>DevSettings</code> from <code>react-native</code>:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> <span class="token maybe-class-name">DevSettings</span> <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native'</span>
</code></pre>
<p>Call the static <code>addMenuItem()</code> in the part of the code you want. It takes two required parameters, <code>title</code> string and <code>handler</code> function. The title is used also as a key, so it needs to be unique for every item you add:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token maybe-class-name">DevSettings</span><span class="token punctuation">.</span><span class="token method function property-access">addMenuItem</span><span class="token punctuation">(</span><span class="token string">'My custom action 1'</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token console class-name">console</span><span class="token punctuation">.</span><span class="token method function property-access">log</span><span class="token punctuation">(</span><span class="token string">'Custom action pressed'</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span>
<span class="token maybe-class-name">DevSettings</span><span class="token punctuation">.</span><span class="token method function property-access">addMenuItem</span><span class="token punctuation">(</span><span class="token string">'My custom action 2'</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token console class-name">console</span><span class="token punctuation">.</span><span class="token method function property-access">log</span><span class="token punctuation">(</span><span class="token string">'Custom action pressed'</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span>
</code></pre>
<p>You can add as many items as you want into the Developer Menu as the list is scrollable. I personally like to wrap the <code>addMenuItem()</code> calls inside <code>__DEV__</code> check so that they only work in dev mode (the developer menu is not included in production builds in any case so this is in a way just for readability, say for example if a person not familiar with React Native is wondering where this code goes.):</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword control-flow">if</span> <span class="token punctuation">(</span>__DEV__<span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token maybe-class-name">DevSettings</span><span class="token punctuation">.</span><span class="token method function property-access">addMenuItem</span><span class="token punctuation">(</span><span class="token string">'My custom action 1'</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
    <span class="token console class-name">console</span><span class="token punctuation">.</span><span class="token method function property-access">log</span><span class="token punctuation">(</span><span class="token string">'Custom action pressed'</span><span class="token punctuation">)</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>
</code></pre>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[GraphQL conditional queries: using skip and variables in Apollo]]></title>
            <link>https://perttu.dev/articles/apollo-graphql-variables-or-skip</link>
            <guid isPermaLink="false">https://perttu.dev/articles/apollo-graphql-variables-or-skip</guid>
            <pubDate>Fri, 23 Feb 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>In a project I'm working on, we have a case where many of the queries ran using the autogenerated Apollo GraphQL hooks that depend on the userID. However, this ID might be null at times, such as when the user's authentication session has ended. This leads to issues in two places:</p>
<ul>
<li>The hook will try to run with a query that has no userID, leading the backend to reject it and return no data/error message on null format userID</li>
<li>Typescript will complain about userID possibly being undefined or null</li>
</ul>
<p>The first one is easy to fix using Apollo GraphQL hooks <code>skip</code> option. However as that does not validate the type, this leads to TypeScript complaining that you're trying to pass a null value to a field that does not accept one (as was in our case). To get around this our team had decided to use non-null assertion with the exclamation mark:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token comment">// eslint-disable-next-line @typescript-eslint/no-non-null-assertion</span>
<span class="token keyword">const</span> <span class="token punctuation">{</span> data <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useRandomQuery</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  variables<span class="token operator">:</span> <span class="token punctuation">{</span>
    userID<span class="token operator">:</span> userID<span class="token operator">!</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">,</span>
  skip<span class="token operator">:</span> <span class="token operator">!</span>userID<span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span>
</code></pre>
<p>Now this works all the way until someone assets a variable as non-null but forgots to add it to the skip section, leading to runtime errors or unexpected behavior.. Also, since our project is relatively mature and enforces several linting rules to capture these kinds of things, people had started adding a lot <code>eslint-disable-next-line @typescript-eslint/no-non-null-assertion</code> comments into these hooks. I think that you should try to always figure out a way around disabling a rule if you're disabling it more than once in your codebase.</p>
<p>To fix this I created the following function:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword">type</span> <span class="token class-name"><span class="token maybe-class-name">NoUndefinedField</span><span class="token operator">&lt;</span><span class="token constant">T</span><span class="token operator">&gt;</span></span> <span class="token operator">=</span> <span class="token punctuation">{</span>
  <span class="token punctuation">[</span><span class="token constant">P</span> <span class="token keyword">in</span> <span class="token keyword">keyof</span> <span class="token constant">T</span><span class="token punctuation">]</span><span class="token operator">-</span><span class="token operator">?</span><span class="token operator">:</span> <span class="token maybe-class-name">NoUndefinedField</span><span class="token operator">&lt;</span><span class="token maybe-class-name">NonNullable</span><span class="token operator">&lt;</span><span class="token constant">T</span><span class="token punctuation">[</span><span class="token constant">P</span><span class="token punctuation">]</span><span class="token operator">&gt;&gt;</span>
<span class="token punctuation">}</span>
<span class="token doc-comment comment">/**
 *
 * <span class="token keyword">@param</span> <span class="token parameter">args</span> query variables for Apollo graphql query
 * <span class="token keyword">@returns</span> either skip: true or variables: NoUndefinedField&lt;TVariables&gt;
 */</span>
<span class="token keyword module">export</span> <span class="token keyword">function</span> <span class="token generic-function"><span class="token function">variablesOrSkip</span><span class="token generic class-name"><span class="token operator">&lt;</span><span class="token maybe-class-name">TVariables</span><span class="token operator">&gt;</span></span></span><span class="token punctuation">(</span>
  args<span class="token operator">:</span> <span class="token maybe-class-name">Partial</span><span class="token operator">&lt;</span><span class="token maybe-class-name">TVariables</span><span class="token operator">&gt;</span><span class="token punctuation">,</span>
<span class="token punctuation">)</span><span class="token operator">:</span> <span class="token punctuation">{</span> skip<span class="token operator">:</span> <span class="token boolean">true</span> <span class="token punctuation">}</span> <span class="token operator">|</span> <span class="token punctuation">{</span> variables<span class="token operator">:</span> <span class="token maybe-class-name">NoUndefinedField</span><span class="token operator">&lt;</span><span class="token maybe-class-name">TVariables</span><span class="token operator">&gt;</span><span class="token punctuation">;</span> skip<span class="token operator">:</span> <span class="token boolean">false</span> <span class="token punctuation">}</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> hasInvalidValues <span class="token operator">=</span> <span class="token known-class-name class-name">Object</span><span class="token punctuation">.</span><span class="token method function property-access">values</span><span class="token punctuation">(</span>args<span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">some</span><span class="token punctuation">(</span>
    <span class="token punctuation">(</span>value<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> value <span class="token operator">===</span> <span class="token keyword null nil">null</span> <span class="token operator">||</span> value <span class="token operator">===</span> <span class="token keyword nil">undefined</span><span class="token punctuation">,</span>
  <span class="token punctuation">)</span>

  <span class="token keyword control-flow">if</span> <span class="token punctuation">(</span>hasInvalidValues<span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword control-flow">return</span> <span class="token punctuation">{</span> skip<span class="token operator">:</span> <span class="token boolean">true</span> <span class="token punctuation">}</span>
  <span class="token punctuation">}</span> <span class="token keyword control-flow">else</span> <span class="token punctuation">{</span>
    <span class="token keyword control-flow">return</span> <span class="token punctuation">{</span> variables<span class="token operator">:</span> args <span class="token keyword module">as</span> <span class="token maybe-class-name">NoUndefinedField</span><span class="token operator">&lt;</span><span class="token maybe-class-name">TVariables</span><span class="token operator">&gt;</span><span class="token punctuation">,</span> skip<span class="token operator">:</span> <span class="token boolean">false</span> <span class="token punctuation">}</span>
  <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<p>What this function does, is that it checks if any of the passed variables are null or undefined by iterating through them, and if any of them are, it will return the skip parameter as true. This is how to use it:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword">const</span> <span class="token punctuation">{</span> data <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useRandomQuery</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  <span class="token spread operator">...</span><span class="token method function property-access">variablesOrSkip</span><span class="token punctuation">(</span><span class="token punctuation">{</span> userID <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>Now if it turns out that userID is undefined or null for some reason the query is not run and you don't end up with nasty or embarrassing problems in productions. Most of the code probably makes a lot of sense but I want to highlight this part:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword">type</span> <span class="token class-name"><span class="token maybe-class-name">NoUndefinedField</span><span class="token operator">&lt;</span><span class="token constant">T</span><span class="token operator">&gt;</span></span> <span class="token operator">=</span> <span class="token punctuation">{</span>
  <span class="token punctuation">[</span><span class="token constant">P</span> <span class="token keyword">in</span> <span class="token keyword">keyof</span> <span class="token constant">T</span><span class="token punctuation">]</span><span class="token operator">-</span><span class="token operator">?</span><span class="token operator">:</span> <span class="token maybe-class-name">NoUndefinedField</span><span class="token operator">&lt;</span><span class="token maybe-class-name">NonNullable</span><span class="token operator">&lt;</span><span class="token constant">T</span><span class="token punctuation">[</span><span class="token constant">P</span><span class="token punctuation">]</span><span class="token operator">&gt;&gt;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>This code here is not my invention, but instead something I found while searching for a way to remove undefined and null values from the returned type which led me to come across this Stack Overflow <a href="https://stackoverflow.com/questions/53050011/remove-null-or-undefined-from-properties-of-a-type">discussion about removing null or undefined values of a type</a></p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[App.js 2025 recap]]></title>
            <link>https://perttu.dev/articles/appjs-2025-recap</link>
            <guid isPermaLink="false">https://perttu.dev/articles/appjs-2025-recap</guid>
            <pubDate>Tue, 03 Jun 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Took a quick visit to Krakow to give a talk at <a href="https://appjs.co/">App.js</a>, right after spending two weeks in Japan. This is actually almost the exact same way I spent last May too. I do wonder if it’s becoming a tradition of sorts to fight jet lag every time I’m visiting Krakow.</p>
<p>So yeah, I was there to give a talk. This time about how to monetize your app with in-app purchases. Funnily enough, when I submitted the talk, I wasn’t working at RevenueCat yet, a company whose entire thing is in-app purchases. So this wasn’t meant to be some “toot my own horn, sponsor-y kind of talk,” but it definitely had the building blocks to become one. Mainly because the truth is, RevenueCat isn’t just the biggest thing in the room when it comes to in-app purchases–it is the room.</p>
<p>I’ll talk a bit about how I think I managed to avoid making it feel like a corporate pitch, but also just want to recap this conference, which continues to be my favorite to date (to be honest, it’s actually tied for first place with ChainReact, which is an unbelievably well-crafted conference).</p>
<h2 id="speakers-dinner-wednesday">Speakers Dinner (Wednesday)</h2>
<p>As mentioned, I came almost directly from Tokyo to Krakow, with a small 4-hour layover at Helsinki airport. I finally landed around noon at Krakow's airport and took a taxi to the hotel the organizers had booked for the speakers. Puro Hotel; the hotel picked by Software Mansion, the conference organizers; didn't disappoint. Well, the gym could have had dumbbells over 14kg, but that's just the meathead in me talking. Otherwise, it was a lovely hotel and served well from Wednesday through Saturday.</p>
<p>I had managed to sleep decently on the flight from Tokyo to Helsinki, thanks to business class seats, so I didn't need a nap. What I did need was to catch up on work and, surprisingly, laundry. I found the nearest self-service laundromat (who knew these make for wonderful co-working spaces) and joined some calls with teammates to get up to speed on the avalanche of things that had happened at RevenueCat while I was away. In traditional RevenueCat fashion: basically a few month's worth of updates packed into two weeks. I ended up doing catchups and calls basically right up until the speakers' dinner at 19:00.</p>
<p>The speakers' dinner was held at Software Mansion's office, just like at RTC.ON last September. Great vibes, good drinks, and solid food. It's funny how many familiar faces you run into at these conferences. Like, I've run into Rafael Mendiola at almost every React Native conference I've been to in the past year. I ended up chatting with some new people too, like Jani Eväkallio, one of the two MCs (to my knowledge), who got his speaker video intro recorded just before me. Oh and Jay Meistrich!</p>
<p>I wish I could have stayed longer but I really had to excuse myself when I started yawning while listening to a pitch. Sorry about that, it wasn’t you, it was just my jet lag.</p>
<h2 id="conference-day-1-thursday">Conference day 1 (Thursday)</h2>
<p>Thanks to jet lag, I woke up at 7. Had a solid hotel breakfast and went for a quick run to get some energy out before diving into a full day of nerding out. Since my talk was going to be the last talk of the conference (on day two), I could take Thursday pretty easy and just enjoy the talks and connect with people.</p>
<p>The main venue is this old brick building in the middle of Krakow’s old jewish district. The venue is sizable, and you can catch the talks from pretty much every part of the venue which is nice. The jewish district is full of great little restaurants and street food vendors, so even if you for some reason skip the food they serve at the conference (I don’t think you should), you will not be disappointed.</p>
<p>I spent the day mostly attending talks, and chatting with Expo folks. Favorite talks of the day ended up being:</p>
<ul>
<li>Life After Legacy: The New Architecture Future, by Nico Corti and Riccardo Cipolleschi.</li>
<li>Legend List: Optimizing for Peak List Performance by Jay Meistrich</li>
<li>WebGPU === High performant 3D animations in React Native, by Krzysztof Piaskowy.</li>
</ul>
<p>Others were great as well, I don’t think there was a single one that I didn’t get something out of. Talks at App.js are really good (he says while being one the speakers himself; talk about implicit self-praise). I have no idea how the speakers are picked, but I’m guessing that the developers at Software Mansion have something to do with it?</p>
<h2 id="conference-day-2-friday">Conference day 2 (Friday)</h2>
<p>The last day of the conference started with a little bit of a scare when I opened my laptop and for some reason Mac’s Spotlight couldn’t find my presentation at all. It wasn’t in Keynote either. I resigned almost immediately and was fully ready to spend most of my day redoing all the slides. However while having breakfast I discovered the Keynote presentation on my phone. I have no idea what it was doing there.</p>
<p>My talk was the very last one of the conference, which to be honest is not the best spot to be since everyone is already itching to get to the after party and start drinking. In the worst case a lot of people have already slid back to their hotels to get ready for the evening. In this case the audience was still mostly there, and energy was good to talk a little bit about in-app purchases.</p>
<p>This talk follows much of the same structure I gave at React Native London end of last year, with the first part focusing on the unique aspects of in-app purchases, and the last focusing on implementation. However I ended up including more statistics about how well React Native apps monetize. Gradually revealing the following points:</p>
<blockquote>
<p>"React Native apps make more money…
…than Flutter apps
…and native apps."</p>
</blockquote>
<img alt="Tweet from user Simek: Did you know that for React Native apps are making more money than Flutter and even the native apps for  RevenueCat? Mid blowing right! 🤯 @plahteenlahti shares the data and breaks down monetization strategies you can implement today with their integrations." loading="lazy" width="1206" height="1286" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftweet.623a1f45.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftweet.623a1f45.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftweet.623a1f45.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>got the response I was hoping for. All the insights about the state of monetization of React Native are based on RevenueCat’s State of Subscription Apps 2025 report, which you should read if you want to uncover industry secrets such as what length of subscriptions should you use with health and fitness apps for example.</p>
<p>Otherwise talk went well, of course there’s still room for improvement and I would kinda like to make a live coding demo at some point. That could be for example showing how I add the necessary code parts and update the paywall in RevenueCat’s paywall editor and how those changes magically get updated on the app. However that would require stripping some fundamental info about subscriptions from the talk, so might need to rethink the whole structure to make more time.</p>
<p>Oh I ran into some Finns after the talk. SOK people, if you’re reading this, I have a bunch of ideas on how to add in-app purchases to your apps. Let's chat.</p>
<p>After the closing ceremony, the afterparty kicked off at the Museum of Japanese Art and Technology, which as a place is great. Due to its size it allows for many different types of spaces, from dancing to casual conversations. Well I mostly just enjoyed the latter ones, no dances for me. Ended up staying late enough, and just chatting and drinking with people.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Great conference! Had even better experience than last year, and last year was also stellar. I will definitely apply to talk again next year. Just have to come up with a unique topic, since although in-app purchases are a nice topic, I would in all honesty talk about something else that has less risk of being a sponsor talk.</p>
<p>You can by the way watch all the talks already from the live stream for <a href="https://www.youtube.com/live/K2JTTKpptGs?si=ER9Nfir6Ba9BWp7r">day 1 here</a>, and for <a href="https://www.youtube.com/live/UTaJlqhTk2g?si=p6rdoOrTPYCtUfJD">day 2 here</a>. My talk is on day 2 at <a href="https://www.youtube.com/live/UTaJlqhTk2g?si=p6rdoOrTPYCtUfJD&amp;t=25906">this time</a>.</p>
<p>Last, huge thanks to Software Mansion for organizing another amazing event. Already looking forward to next year; and maybe, just maybe, without jet lag.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Automating social cards for Gatsby.js]]></title>
            <link>https://perttu.dev/articles/automating-social-cards-for-gatsby</link>
            <guid isPermaLink="false">https://perttu.dev/articles/automating-social-cards-for-gatsby</guid>
            <pubDate>Tue, 20 Mar 2018 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>If you share any of my blog posts on Twitter, Facebook, or LinkedIn, you should see something like a branded image card showing up in your share preview. These are commonly known as OG (Open Graph) image sharing cards, and almost every social media platform supports them when sharing links.</p>
<p>Having sharing images when distributing your content around the web can really help with more people noticing your content. A picture pops out more from a text-filled Twitter feed. Social images with brand styling also help capture your audience's attention, as they might recognize your brand colors and fonts better than just a random image in their feed.</p>
<p>You can quite easily add this functionality to your website by altering a few meta tags and finding an accompanying image for every page on your blog. But what if you don't want to use photos? Or perhaps your website has hundreds of pages and finding a good photo for each page—or creating them yourself individually—just isn't a viable strategy. In this case, you can look into creating an automated process to generate these images.</p>
<p>In this tutorial, we're going to look at how to generate social sharing images automatically by building a custom Gatsby plugin that uses Jimp, a Node.js image manipulation library. Our sharing cards will display the title of our blog post, the most relevant tag, and the estimated reading time. The tutorial consists of five parts:</p>
<ol>
<li>What are OG-tags and why use them</li>
<li>Setting React Helmet to support OG-tags and Twitter tags</li>
<li>Modifying the blog-post template to support tags and reading time</li>
<li>Creating the social sharing image template</li>
<li>Building the gatsby-plugin-og-images plugin</li>
</ol>
<h2 id="what-are-og-tags-and-why-use-them">What are OG-tags and why use them</h2>
<p>OG-tags (Open Graph tags) are meta tags in your page's <code>&lt;head&gt;</code> that social platforms use for link previews. When someone shares a link to your blog post on Facebook, LinkedIn, or other platforms, these tags tell the platform what title, description, and image to display.</p>
<p>Twitter also supports its own set of tags (Twitter Cards), and for maximum compatibility, you'll want to implement both.</p>
<p>The basic OG tags you need are:</p>
<ul>
<li><code>og:title</code> — The title of your page</li>
<li><code>og:description</code> — A brief description</li>
<li><code>og:image</code> — The URL to the preview image (1200×630 pixels is ideal)</li>
<li><code>og:url</code> — The canonical URL of your page</li>
<li><code>og:type</code> — The type of content (e.g., "article" for blog posts)</li>
</ul>
<h2 id="setting-react-helmet-to-support-og-tags-and-twitter-tags">Setting React Helmet to support OG-tags and Twitter tags</h2>
<p>Let's start by modifying our Gatsby site to support OG-tags and Twitter tags. First, install the required packages:</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">npm</span> <span class="token function">install</span> react-helmet gatsby-plugin-react-helmet
</code></pre>
<p>Add the plugin to your <code>gatsby-config.js</code>:</p>
<pre class="language-javascript"><code class="language-javascript">module<span class="token punctuation">.</span><span class="token property-access">exports</span> <span class="token operator">=</span> <span class="token punctuation">{</span>
  <span class="token literal-property property">siteMetadata</span><span class="token operator">:</span> <span class="token punctuation">{</span>
    <span class="token literal-property property">title</span><span class="token operator">:</span> <span class="token string">"My Blog"</span><span class="token punctuation">,</span>
    <span class="token literal-property property">description</span><span class="token operator">:</span> <span class="token string">"Thoughts on building things."</span><span class="token punctuation">,</span>
    <span class="token literal-property property">siteUrl</span><span class="token operator">:</span> <span class="token string">"https://example.com"</span><span class="token punctuation">,</span>
    <span class="token literal-property property">twitterUsername</span><span class="token operator">:</span> <span class="token string">"@yourhandle"</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">,</span>
  <span class="token literal-property property">plugins</span><span class="token operator">:</span> <span class="token punctuation">[</span>
    <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">gatsby-plugin-react-helmet</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
    <span class="token comment">// ...other plugins</span>
  <span class="token punctuation">]</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Now create or update your SEO component at <code>src/components/seo.js</code>:</p>
<pre class="language-jsx"><code class="language-jsx"><span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">React</span></span> <span class="token keyword module">from</span> <span class="token string">"react"</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> <span class="token maybe-class-name">Helmet</span> <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">"react-helmet"</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> useStaticQuery<span class="token punctuation">,</span> graphql <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">"gatsby"</span>

<span class="token keyword module">export</span> <span class="token keyword module">default</span> <span class="token keyword">function</span> <span class="token constant">SEO</span><span class="token punctuation">(</span><span class="token parameter"><span class="token punctuation">{</span>
  title<span class="token punctuation">,</span>
  description<span class="token punctuation">,</span>
  pathname<span class="token punctuation">,</span>
  image<span class="token punctuation">,</span>
  article <span class="token operator">=</span> <span class="token boolean">false</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span></span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">{</span> site <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useStaticQuery</span><span class="token punctuation">(</span>graphql<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token graphql language-graphql">
    <span class="token keyword">query</span> <span class="token punctuation">{</span>
      <span class="token object">site</span> <span class="token punctuation">{</span>
        <span class="token object">siteMetadata</span> <span class="token punctuation">{</span>
          <span class="token property">title</span>
          <span class="token property">description</span>
          <span class="token property">siteUrl</span>
          <span class="token property">twitterUsername</span>
        <span class="token punctuation">}</span>
      <span class="token punctuation">}</span>
    <span class="token punctuation">}</span>
  </span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span>

  <span class="token keyword">const</span> <span class="token punctuation">{</span>
    siteUrl<span class="token punctuation">,</span>
    <span class="token literal-property property">title</span><span class="token operator">:</span> defaultTitle<span class="token punctuation">,</span>
    <span class="token literal-property property">description</span><span class="token operator">:</span> defaultDescription<span class="token punctuation">,</span>
    twitterUsername<span class="token punctuation">,</span>
  <span class="token punctuation">}</span> <span class="token operator">=</span> site<span class="token punctuation">.</span><span class="token property-access">siteMetadata</span>

  <span class="token keyword">const</span> seo <span class="token operator">=</span> <span class="token punctuation">{</span>
    <span class="token literal-property property">title</span><span class="token operator">:</span> title <span class="token operator">||</span> defaultTitle<span class="token punctuation">,</span>
    <span class="token literal-property property">description</span><span class="token operator">:</span> description <span class="token operator">||</span> defaultDescription<span class="token punctuation">,</span>
    <span class="token literal-property property">url</span><span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>siteUrl<span class="token interpolation-punctuation punctuation">}</span></span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>pathname <span class="token operator">||</span> <span class="token string">"/"</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
    <span class="token literal-property property">image</span><span class="token operator">:</span> image <span class="token operator">?</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>siteUrl<span class="token interpolation-punctuation punctuation">}</span></span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>image<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span> <span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>siteUrl<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">/og/default.png</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span>

  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Helmet</span></span> <span class="token attr-name">title</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>seo<span class="token punctuation">.</span><span class="token property-access">title</span><span class="token punctuation">}</span></span> <span class="token attr-name">titleTemplate</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">%s | </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>defaultTitle<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>html</span> <span class="token attr-name">lang</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>en<span class="token punctuation">"</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">name</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>description<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>seo<span class="token punctuation">.</span><span class="token property-access">description</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">

      </span><span class="token punctuation">{</span><span class="token comment">/* Open Graph */</span><span class="token punctuation">}</span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">property</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>og:url<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>seo<span class="token punctuation">.</span><span class="token property-access">url</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">property</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>og:type<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>article <span class="token operator">?</span> <span class="token string">"article"</span> <span class="token operator">:</span> <span class="token string">"website"</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">property</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>og:title<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>seo<span class="token punctuation">.</span><span class="token property-access">title</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">property</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>og:description<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>seo<span class="token punctuation">.</span><span class="token property-access">description</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">property</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>og:image<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>seo<span class="token punctuation">.</span><span class="token property-access">image</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">

      </span><span class="token punctuation">{</span><span class="token comment">/* Twitter */</span><span class="token punctuation">}</span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">name</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>twitter:card<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>summary_large_image<span class="token punctuation">"</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token punctuation">{</span>twitterUsername <span class="token operator">&amp;&amp;</span> <span class="token punctuation">(</span>
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">name</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>twitter:creator<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>twitterUsername<span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span>
      <span class="token punctuation">)</span><span class="token punctuation">}</span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">name</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>twitter:title<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>seo<span class="token punctuation">.</span><span class="token property-access">title</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">name</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>twitter:description<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>seo<span class="token punctuation">.</span><span class="token property-access">description</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">name</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>twitter:image<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>seo<span class="token punctuation">.</span><span class="token property-access">image</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Helmet</span></span><span class="token punctuation">&gt;</span></span>
  <span class="token punctuation">)</span>
<span class="token punctuation">}</span>
</code></pre>
<p>This component handles both Open Graph tags for Facebook/LinkedIn and Twitter Card tags. The <code>image</code> prop will be the path to our auto-generated OG image.</p>
<h2 id="modifying-the-blog-post-template-to-support-tags-and-reading-time">Modifying the blog-post template to support tags and reading time</h2>
<p>To display the tag and reading time on our OG images, we need to ensure this data is available. Install the reading time plugin:</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">npm</span> <span class="token function">install</span> gatsby-remark-reading-time
</code></pre>
<p>Add it to your <code>gatsby-config.js</code>:</p>
<pre class="language-javascript"><code class="language-javascript">module<span class="token punctuation">.</span><span class="token property-access">exports</span> <span class="token operator">=</span> <span class="token punctuation">{</span>
  <span class="token literal-property property">plugins</span><span class="token operator">:</span> <span class="token punctuation">[</span>
    <span class="token punctuation">{</span>
      <span class="token literal-property property">resolve</span><span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">gatsby-transformer-remark</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
      <span class="token literal-property property">options</span><span class="token operator">:</span> <span class="token punctuation">{</span>
        <span class="token literal-property property">plugins</span><span class="token operator">:</span> <span class="token punctuation">[</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">gatsby-remark-reading-time</span><span class="token template-punctuation string">`</span></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>
  <span class="token punctuation">]</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Now update your blog post template at <code>src/templates/blog-post.js</code>:</p>
<pre class="language-jsx"><code class="language-jsx"><span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">React</span></span> <span class="token keyword module">from</span> <span class="token string">"react"</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> graphql <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">"gatsby"</span>
<span class="token keyword module">import</span> <span class="token constant">SEO</span> <span class="token keyword module">from</span> <span class="token string">"../components/seo"</span>

<span class="token keyword module">export</span> <span class="token keyword module">default</span> <span class="token keyword">function</span> <span class="token function"><span class="token maybe-class-name">BlogPostTemplate</span></span><span class="token punctuation">(</span><span class="token parameter"><span class="token punctuation">{</span> data<span class="token punctuation">,</span> <span class="token dom variable">location</span> <span class="token punctuation">}</span></span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> post <span class="token operator">=</span> data<span class="token punctuation">.</span><span class="token property-access">markdownRemark</span>
  <span class="token keyword">const</span> title <span class="token operator">=</span> post<span class="token punctuation">.</span><span class="token property-access">frontmatter</span><span class="token punctuation">.</span><span class="token property-access">title</span>
  <span class="token keyword">const</span> description <span class="token operator">=</span> post<span class="token punctuation">.</span><span class="token property-access">frontmatter</span><span class="token punctuation">.</span><span class="token property-access">description</span> <span class="token operator">||</span> post<span class="token punctuation">.</span><span class="token property-access">excerpt</span>
  <span class="token keyword">const</span> tags <span class="token operator">=</span> post<span class="token punctuation">.</span><span class="token property-access">frontmatter</span><span class="token punctuation">.</span><span class="token property-access">tags</span> <span class="token operator">||</span> <span class="token punctuation">[</span><span class="token punctuation">]</span>
  <span class="token keyword">const</span> primaryTag <span class="token operator">=</span> tags<span class="token punctuation">[</span><span class="token number">0</span><span class="token punctuation">]</span> <span class="token operator">||</span> <span class="token string">"Blog"</span>
  <span class="token keyword">const</span> readingTime <span class="token operator">=</span> post<span class="token punctuation">.</span><span class="token property-access">fields</span><span class="token punctuation">.</span><span class="token property-access">readingTime</span><span class="token operator">?.</span>text <span class="token operator">||</span> <span class="token string">"5 min read"</span>

  <span class="token comment">// This path will be generated by our plugin</span>
  <span class="token keyword">const</span> ogImage <span class="token operator">=</span> post<span class="token punctuation">.</span><span class="token property-access">fields</span><span class="token punctuation">.</span><span class="token property-access">ogImage</span>

  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">SEO</span></span>
        <span class="token attr-name">title</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>title<span class="token punctuation">}</span></span>
        <span class="token attr-name">description</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>description<span class="token punctuation">}</span></span>
        <span class="token attr-name">pathname</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token dom variable">location</span><span class="token punctuation">.</span><span class="token property-access">pathname</span><span class="token punctuation">}</span></span>
        <span class="token attr-name">image</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>ogImage<span class="token punctuation">}</span></span>
        <span class="token attr-name">article</span>
      <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>article</span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
        </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>h1</span><span class="token punctuation">&gt;</span></span><span class="token punctuation">{</span>title<span class="token punctuation">}</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>h1</span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
        </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>p</span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
          </span><span class="token punctuation">{</span>primaryTag<span class="token punctuation">}</span><span class="token plain-text"> • </span><span class="token punctuation">{</span>readingTime<span class="token punctuation">}</span><span class="token plain-text">
        </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>p</span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
        </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">dangerouslySetInnerHTML</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">{</span> <span class="token literal-property property">__html</span><span class="token operator">:</span> post<span class="token punctuation">.</span><span class="token property-access">html</span> <span class="token punctuation">}</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>article</span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span></span><span class="token punctuation">&gt;</span></span>
  <span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token keyword module">export</span> <span class="token keyword">const</span> pageQuery <span class="token operator">=</span> graphql<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token graphql language-graphql">
  <span class="token keyword">query</span> <span class="token definition-query function">BlogPostBySlug</span><span class="token punctuation">(</span><span class="token variable">$id</span><span class="token punctuation">:</span> <span class="token scalar">String</span><span class="token operator">!</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token property-query">markdownRemark</span><span class="token punctuation">(</span><span class="token attr-name">id</span><span class="token punctuation">:</span> <span class="token punctuation">{</span> <span class="token attr-name">eq</span><span class="token punctuation">:</span> <span class="token variable">$id</span> <span class="token punctuation">}</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
      <span class="token property">id</span>
      <span class="token property">html</span>
      <span class="token property-query">excerpt</span><span class="token punctuation">(</span><span class="token attr-name">pruneLength</span><span class="token punctuation">:</span> <span class="token number">160</span><span class="token punctuation">)</span>
      <span class="token object">frontmatter</span> <span class="token punctuation">{</span>
        <span class="token property">title</span>
        <span class="token property">description</span>
        <span class="token property">tags</span>
      <span class="token punctuation">}</span>
      <span class="token object">fields</span> <span class="token punctuation">{</span>
        <span class="token property">slug</span>
        <span class="token object">readingTime</span> <span class="token punctuation">{</span>
          <span class="token property">text</span>
          <span class="token property">minutes</span>
          <span class="token property">time</span>
          <span class="token property">words</span>
        <span class="token punctuation">}</span>
        <span class="token property">ogImage</span>
      <span class="token punctuation">}</span>
    <span class="token punctuation">}</span>
  <span class="token punctuation">}</span>
</span><span class="token template-punctuation string">`</span></span>
</code></pre>
<h2 id="creating-the-social-sharing-image-template">Creating the social sharing image template</h2>
<p>We want our social sharing cards to follow a consistent theme, with only the text changing between posts. Create a base template image:</p>
<ul>
<li><strong>Dimensions</strong>: 1200×630 pixels (the standard OG image size)</li>
<li><strong>Include</strong>: Your brand colors, logo, domain name, and any decorative elements</li>
<li><strong>Leave space</strong>: For the title, tag, and reading time text</li>
</ul>
<p>Save this template image to <code>static/og/template.png</code> in your Gatsby project.</p>
<p>For the text, we'll use Jimp's built-in fonts initially. If you need custom fonts, you can create bitmap fonts (<code>.fnt</code> files) using tools like <a href="https://libgdx.com/wiki/tools/hiero">Hiero</a> or <a href="https://www.angelcode.com/products/bmfont/">BMFont</a>.</p>
<p>When designing your template, measure the pixel offsets from the edges where you want the text to appear. You'll need these values when positioning text in the plugin.</p>
<h2 id="building-the-gatsby-plugin-og-images-plugin">Building the gatsby-plugin-og-images plugin</h2>
<p>Now for the main event: building a custom Gatsby plugin that generates OG images during the build process.</p>
<h3 id="plugin-folder-structure">Plugin folder structure</h3>
<p>Create the following structure in your Gatsby project:</p>
<pre class="language-text"><code class="language-text">plugins/
  gatsby-plugin-og-images/
    package.json
    index.js
    gatsby-node.js
</code></pre>
<h3 id="packagejson">package.json</h3>
<pre class="language-json"><code class="language-json"><span class="token punctuation">{</span>
  <span class="token property">"name"</span><span class="token operator">:</span> <span class="token string">"gatsby-plugin-og-images"</span><span class="token punctuation">,</span>
  <span class="token property">"version"</span><span class="token operator">:</span> <span class="token string">"1.0.0"</span><span class="token punctuation">,</span>
  <span class="token property">"main"</span><span class="token operator">:</span> <span class="token string">"index.js"</span><span class="token punctuation">,</span>
  <span class="token property">"dependencies"</span><span class="token operator">:</span> <span class="token punctuation">{</span>
    <span class="token property">"jimp"</span><span class="token operator">:</span> <span class="token string">"^0.22.12"</span><span class="token punctuation">,</span>
    <span class="token property">"mkdirp"</span><span class="token operator">:</span> <span class="token string">"^3.0.1"</span>
  <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Run <code>npm install</code> inside the plugin folder to install the dependencies.</p>
<h3 id="indexjs">index.js</h3>
<p>This file just needs to export an empty object:</p>
<pre class="language-javascript"><code class="language-javascript">module<span class="token punctuation">.</span><span class="token property-access">exports</span> <span class="token operator">=</span> <span class="token punctuation">{</span><span class="token punctuation">}</span>
</code></pre>
<h3 id="gatsby-nodejs">gatsby-node.js</h3>
<p>This is where the magic happens. The plugin will:</p>
<ol>
<li>Find all Markdown posts during the build</li>
<li>Generate an image for each post at <code>public/og/&lt;slug&gt;.png</code></li>
<li>Add an <code>ogImage</code> field to each post node so templates can query it</li>
</ol>
<pre class="language-javascript"><code class="language-javascript"><span class="token keyword">const</span> path <span class="token operator">=</span> <span class="token function">require</span><span class="token punctuation">(</span><span class="token string">"path"</span><span class="token punctuation">)</span>
<span class="token keyword">const</span> fs <span class="token operator">=</span> <span class="token function">require</span><span class="token punctuation">(</span><span class="token string">"fs"</span><span class="token punctuation">)</span>
<span class="token keyword">const</span> mkdirp <span class="token operator">=</span> <span class="token function">require</span><span class="token punctuation">(</span><span class="token string">"mkdirp"</span><span class="token punctuation">)</span>
<span class="token keyword">const</span> <span class="token maybe-class-name">Jimp</span> <span class="token operator">=</span> <span class="token function">require</span><span class="token punctuation">(</span><span class="token string">"jimp"</span><span class="token punctuation">)</span>

<span class="token keyword">function</span> <span class="token function">safeFileName</span><span class="token punctuation">(</span><span class="token parameter">input</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> input
    <span class="token punctuation">.</span><span class="token method function property-access">toLowerCase</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">replace</span><span class="token punctuation">(</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">[^a-z0-9]+</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span> <span class="token string">"-"</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">replace</span><span class="token punctuation">(</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">(^-|-$)+</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span> <span class="token string">""</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token keyword">async</span> <span class="token keyword">function</span> <span class="token function">generateOgImage</span><span class="token punctuation">(</span><span class="token parameter"><span class="token punctuation">{</span>
  outputPath<span class="token punctuation">,</span>
  title<span class="token punctuation">,</span>
  tag<span class="token punctuation">,</span>
  readingTime<span class="token punctuation">,</span>
  templatePath<span class="token punctuation">,</span>
<span class="token punctuation">}</span></span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> image <span class="token operator">=</span> <span class="token keyword control-flow">await</span> <span class="token maybe-class-name">Jimp</span><span class="token punctuation">.</span><span class="token method function property-access">read</span><span class="token punctuation">(</span>templatePath<span class="token punctuation">)</span>

  <span class="token comment">// Load built-in fonts (you can replace with custom bitmap fonts)</span>
  <span class="token keyword">const</span> titleFont <span class="token operator">=</span> <span class="token keyword control-flow">await</span> <span class="token maybe-class-name">Jimp</span><span class="token punctuation">.</span><span class="token method function property-access">loadFont</span><span class="token punctuation">(</span><span class="token maybe-class-name">Jimp</span><span class="token punctuation">.</span><span class="token constant">FONT_SANS_64_BLACK</span><span class="token punctuation">)</span>
  <span class="token keyword">const</span> metaFont <span class="token operator">=</span> <span class="token keyword control-flow">await</span> <span class="token maybe-class-name">Jimp</span><span class="token punctuation">.</span><span class="token method function property-access">loadFont</span><span class="token punctuation">(</span><span class="token maybe-class-name">Jimp</span><span class="token punctuation">.</span><span class="token constant">FONT_SANS_32_BLACK</span><span class="token punctuation">)</span>

  <span class="token comment">// Layout constants - adjust these to match your template</span>
  <span class="token keyword">const</span> <span class="token constant">LEFT_MARGIN</span> <span class="token operator">=</span> <span class="token number">80</span>
  <span class="token keyword">const</span> <span class="token constant">TITLE_TOP</span> <span class="token operator">=</span> <span class="token number">110</span>
  <span class="token keyword">const</span> <span class="token constant">TITLE_MAX_WIDTH</span> <span class="token operator">=</span> <span class="token number">1040</span>
  <span class="token keyword">const</span> <span class="token constant">TITLE_MAX_HEIGHT</span> <span class="token operator">=</span> <span class="token number">240</span>
  <span class="token keyword">const</span> <span class="token constant">META_TOP</span> <span class="token operator">=</span> <span class="token number">420</span>

  <span class="token comment">// Print the title (with word wrap)</span>
  image<span class="token punctuation">.</span><span class="token method function property-access">print</span><span class="token punctuation">(</span>
    titleFont<span class="token punctuation">,</span>
    <span class="token constant">LEFT_MARGIN</span><span class="token punctuation">,</span>
    <span class="token constant">TITLE_TOP</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>
      <span class="token literal-property property">text</span><span class="token operator">:</span> title<span class="token punctuation">,</span>
      <span class="token literal-property property">alignmentX</span><span class="token operator">:</span> <span class="token maybe-class-name">Jimp</span><span class="token punctuation">.</span><span class="token constant">HORIZONTAL_ALIGN_LEFT</span><span class="token punctuation">,</span>
      <span class="token literal-property property">alignmentY</span><span class="token operator">:</span> <span class="token maybe-class-name">Jimp</span><span class="token punctuation">.</span><span class="token constant">VERTICAL_ALIGN_TOP</span><span class="token punctuation">,</span>
    <span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token constant">TITLE_MAX_WIDTH</span><span class="token punctuation">,</span>
    <span class="token constant">TITLE_MAX_HEIGHT</span>
  <span class="token punctuation">)</span>

  <span class="token comment">// Print the tag and reading time</span>
  <span class="token keyword">const</span> metaText <span class="token operator">=</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>tag<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string"> • </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>readingTime<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span>
  image<span class="token punctuation">.</span><span class="token method function property-access">print</span><span class="token punctuation">(</span>metaFont<span class="token punctuation">,</span> <span class="token constant">LEFT_MARGIN</span><span class="token punctuation">,</span> <span class="token constant">META_TOP</span><span class="token punctuation">,</span> metaText<span class="token punctuation">)</span>

  <span class="token keyword control-flow">await</span> image<span class="token punctuation">.</span><span class="token method function property-access">writeAsync</span><span class="token punctuation">(</span>outputPath<span class="token punctuation">)</span>
<span class="token punctuation">}</span>

exports<span class="token punctuation">.</span><span class="token method-variable function-variable method function property-access">onPostBuild</span> <span class="token operator">=</span> <span class="token keyword">async</span> <span class="token punctuation">(</span><span class="token parameter"><span class="token punctuation">{</span> graphql<span class="token punctuation">,</span> reporter <span class="token punctuation">}</span><span class="token punctuation">,</span> pluginOptions</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> templatePath <span class="token operator">=</span>
    pluginOptions<span class="token punctuation">.</span><span class="token property-access">templatePath</span> <span class="token operator">||</span>
    path<span class="token punctuation">.</span><span class="token method function property-access">resolve</span><span class="token punctuation">(</span>process<span class="token punctuation">.</span><span class="token method function property-access">cwd</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token string">"static/og/template.png"</span><span class="token punctuation">)</span>

  <span class="token keyword control-flow">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>fs<span class="token punctuation">.</span><span class="token method function property-access">existsSync</span><span class="token punctuation">(</span>templatePath<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    reporter<span class="token punctuation">.</span><span class="token method function property-access">panic</span><span class="token punctuation">(</span>
      <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">[gatsby-plugin-og-images] Missing template image at: </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>templatePath<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span>
    <span class="token punctuation">)</span>
  <span class="token punctuation">}</span>

  <span class="token keyword">const</span> result <span class="token operator">=</span> <span class="token keyword control-flow">await</span> <span class="token function">graphql</span><span class="token punctuation">(</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">
    {
      allMarkdownRemark {
        nodes {
          id
          frontmatter {
            title
            tags
          }
          fields {
            slug
            readingTime {
              text
            }
          }
        }
      }
    }
  </span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span>

  <span class="token keyword control-flow">if</span> <span class="token punctuation">(</span>result<span class="token punctuation">.</span><span class="token property-access">errors</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    reporter<span class="token punctuation">.</span><span class="token method function property-access">panic</span><span class="token punctuation">(</span>
      <span class="token string">"[gatsby-plugin-og-images] GraphQL query failed"</span><span class="token punctuation">,</span>
      result<span class="token punctuation">.</span><span class="token property-access">errors</span>
    <span class="token punctuation">)</span>
  <span class="token punctuation">}</span>

  <span class="token keyword">const</span> posts <span class="token operator">=</span> result<span class="token punctuation">.</span><span class="token property-access">data</span><span class="token punctuation">.</span><span class="token property-access">allMarkdownRemark</span><span class="token punctuation">.</span><span class="token property-access">nodes</span>
  <span class="token keyword">const</span> outDir <span class="token operator">=</span> path<span class="token punctuation">.</span><span class="token method function property-access">resolve</span><span class="token punctuation">(</span>process<span class="token punctuation">.</span><span class="token method function property-access">cwd</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token string">"public/og"</span><span class="token punctuation">)</span>

  <span class="token keyword control-flow">await</span> <span class="token function">mkdirp</span><span class="token punctuation">(</span>outDir<span class="token punctuation">)</span>

  reporter<span class="token punctuation">.</span><span class="token method function property-access">info</span><span class="token punctuation">(</span>
    <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">[gatsby-plugin-og-images] Generating </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>posts<span class="token punctuation">.</span><span class="token property-access">length</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string"> OG images...</span><span class="token template-punctuation string">`</span></span>
  <span class="token punctuation">)</span>

  <span class="token keyword control-flow">for</span> <span class="token punctuation">(</span><span class="token keyword">const</span> post <span class="token keyword">of</span> posts<span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword">const</span> title <span class="token operator">=</span> post<span class="token punctuation">.</span><span class="token property-access">frontmatter</span><span class="token punctuation">.</span><span class="token property-access">title</span> <span class="token operator">||</span> <span class="token string">"Untitled"</span>
    <span class="token keyword">const</span> tag <span class="token operator">=</span> post<span class="token punctuation">.</span><span class="token property-access">frontmatter</span><span class="token punctuation">.</span><span class="token property-access">tags</span><span class="token operator">?.</span><span class="token punctuation">[</span><span class="token number">0</span><span class="token punctuation">]</span> <span class="token operator">||</span> <span class="token string">"Blog"</span>
    <span class="token keyword">const</span> readingTime <span class="token operator">=</span> post<span class="token punctuation">.</span><span class="token property-access">fields</span><span class="token punctuation">.</span><span class="token property-access">readingTime</span><span class="token operator">?.</span>text <span class="token operator">||</span> <span class="token string">"5 min read"</span>

    <span class="token keyword">const</span> slug <span class="token operator">=</span> post<span class="token punctuation">.</span><span class="token property-access">fields</span><span class="token punctuation">.</span><span class="token property-access">slug</span> <span class="token operator">||</span> <span class="token function">safeFileName</span><span class="token punctuation">(</span>title<span class="token punctuation">)</span>
    <span class="token keyword">const</span> fileName <span class="token operator">=</span> <span class="token function">safeFileName</span><span class="token punctuation">(</span>slug<span class="token punctuation">.</span><span class="token method function property-access">replace</span><span class="token punctuation">(</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">\/</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span> <span class="token string">"-"</span><span class="token punctuation">)</span><span class="token punctuation">)</span>
    <span class="token keyword">const</span> outputPath <span class="token operator">=</span> path<span class="token punctuation">.</span><span class="token method function property-access">join</span><span class="token punctuation">(</span>outDir<span class="token punctuation">,</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>fileName<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">.png</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span>

    <span class="token keyword control-flow">try</span> <span class="token punctuation">{</span>
      <span class="token keyword control-flow">await</span> <span class="token function">generateOgImage</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
        outputPath<span class="token punctuation">,</span>
        title<span class="token punctuation">,</span>
        tag<span class="token punctuation">,</span>
        readingTime<span class="token punctuation">,</span>
        templatePath<span class="token punctuation">,</span>
      <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token punctuation">}</span> <span class="token keyword control-flow">catch</span> <span class="token punctuation">(</span>e<span class="token punctuation">)</span> <span class="token punctuation">{</span>
      reporter<span class="token punctuation">.</span><span class="token method function property-access">warn</span><span class="token punctuation">(</span>
        <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">[gatsby-plugin-og-images] Failed generating image for </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>slug<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">: </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>e<span class="token punctuation">.</span><span class="token property-access">message</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span>
      <span class="token punctuation">)</span>
    <span class="token punctuation">}</span>
  <span class="token punctuation">}</span>

  reporter<span class="token punctuation">.</span><span class="token method function property-access">info</span><span class="token punctuation">(</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">[gatsby-plugin-og-images] Done.</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>

exports<span class="token punctuation">.</span><span class="token method-variable function-variable method function property-access">onCreateNode</span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token parameter"><span class="token punctuation">{</span> node<span class="token punctuation">,</span> actions <span class="token punctuation">}</span></span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">{</span> createNodeField <span class="token punctuation">}</span> <span class="token operator">=</span> actions

  <span class="token comment">// Add ogImage field to Markdown nodes so templates can query it</span>
  <span class="token keyword control-flow">if</span> <span class="token punctuation">(</span>node<span class="token punctuation">.</span><span class="token property-access">internal</span><span class="token punctuation">.</span><span class="token property-access">type</span> <span class="token operator">===</span> <span class="token string">"MarkdownRemark"</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword">const</span> slugField <span class="token operator">=</span> node<span class="token punctuation">.</span><span class="token property-access">fields</span><span class="token operator">?.</span>slug
    <span class="token keyword control-flow">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>slugField<span class="token punctuation">)</span> <span class="token keyword control-flow">return</span>

    <span class="token keyword">const</span> fileName <span class="token operator">=</span> <span class="token function">safeFileName</span><span class="token punctuation">(</span>slugField<span class="token punctuation">.</span><span class="token method function property-access">replace</span><span class="token punctuation">(</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">\/</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span> <span class="token string">"-"</span><span class="token punctuation">)</span><span class="token punctuation">)</span>
    <span class="token function">createNodeField</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
      node<span class="token punctuation">,</span>
      <span class="token literal-property property">name</span><span class="token operator">:</span> <span class="token string">"ogImage"</span><span class="token punctuation">,</span>
      <span class="token literal-property property">value</span><span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">/og/</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>fileName<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">.png</span><span class="token template-punctuation string">`</span></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>
<h3 id="adding-the-plugin-to-your-site">Adding the plugin to your site</h3>
<p>In your main <code>gatsby-config.js</code>, add the plugin:</p>
<pre class="language-javascript"><code class="language-javascript">module<span class="token punctuation">.</span><span class="token property-access">exports</span> <span class="token operator">=</span> <span class="token punctuation">{</span>
  <span class="token literal-property property">plugins</span><span class="token operator">:</span> <span class="token punctuation">[</span>
    <span class="token punctuation">{</span>
      <span class="token literal-property property">resolve</span><span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">gatsby-plugin-og-images</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
      <span class="token literal-property property">options</span><span class="token operator">:</span> <span class="token punctuation">{</span>
        <span class="token literal-property property">templatePath</span><span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>__dirname<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">/static/og/template.png</span><span class="token template-punctuation string">`</span></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 comment">// ...other plugins</span>
  <span class="token punctuation">]</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Now when you run <code>gatsby build</code>, you'll see images generated in <code>public/og/</code> and your blog posts will automatically include the correct OG image meta tags.</p>
<h2 id="usage-with-contentful">Usage with Contentful</h2>
<p>If you're using Contentful instead of Markdown files, you'll need to modify the GraphQL queries. In the <code>onPostBuild</code> function, change the query to:</p>
<pre class="language-javascript"><code class="language-javascript"><span class="token keyword">const</span> result <span class="token operator">=</span> <span class="token keyword control-flow">await</span> <span class="token function">graphql</span><span class="token punctuation">(</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">
  {
    allContentfulBlogPost {
      nodes {
        id
        title
        tags
        slug
        body {
          childMarkdownRemark {
            fields {
              readingTime {
                text
              }
            }
          }
        }
      }
    }
  }
</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span>
</code></pre>
<p>And update the <code>onCreateNode</code> function to handle Contentful nodes:</p>
<pre class="language-javascript"><code class="language-javascript">exports<span class="token punctuation">.</span><span class="token method-variable function-variable method function property-access">onCreateNode</span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token parameter"><span class="token punctuation">{</span> node<span class="token punctuation">,</span> actions <span class="token punctuation">}</span></span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">{</span> createNodeField <span class="token punctuation">}</span> <span class="token operator">=</span> actions

  <span class="token keyword control-flow">if</span> <span class="token punctuation">(</span>node<span class="token punctuation">.</span><span class="token property-access">internal</span><span class="token punctuation">.</span><span class="token property-access">type</span> <span class="token operator">===</span> <span class="token string">"ContentfulBlogPost"</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword">const</span> fileName <span class="token operator">=</span> <span class="token function">safeFileName</span><span class="token punctuation">(</span>node<span class="token punctuation">.</span><span class="token property-access">slug</span><span class="token punctuation">)</span>
    <span class="token function">createNodeField</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
      node<span class="token punctuation">,</span>
      <span class="token literal-property property">name</span><span class="token operator">:</span> <span class="token string">"ogImage"</span><span class="token punctuation">,</span>
      <span class="token literal-property property">value</span><span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">/og/</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>fileName<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">.png</span><span class="token template-punctuation string">`</span></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>
<h2 id="testing-your-og-images">Testing your OG images</h2>
<p>After building your site, test your OG images using these tools:</p>
<ol>
<li><strong>Twitter Card Validator</strong> — Test how your cards will appear on Twitter</li>
<li><strong>Facebook Sharing Debugger</strong> — Debug and preview Facebook shares</li>
<li><strong>LinkedIn Post Inspector</strong> — Check how LinkedIn will display your links</li>
</ol>
<p>These platforms cache OG images aggressively, so use the debug tools to force a refresh when testing changes.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Automating OG image generation saves time and ensures consistency across your blog. With this plugin, every new post automatically gets a branded social sharing image without any manual work.</p>
<p>The key benefits:</p>
<ul>
<li><strong>Consistency</strong>: Every post follows your brand guidelines</li>
<li><strong>Automation</strong>: No manual image creation needed</li>
<li><strong>Dynamic content</strong>: Title, tags, and reading time are always up to date</li>
<li><strong>SEO improvement</strong>: Better social previews lead to higher click-through rates</li>
</ul>
<p>Feel free to customize the plugin to match your design—adjust the font sizes, positions, colors, and add any additional elements you want on your cards.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[App Store release checklist]]></title>
            <link>https://perttu.dev/articles/checklist-for-releasing-apps</link>
            <guid isPermaLink="false">https://perttu.dev/articles/checklist-for-releasing-apps</guid>
            <pubDate>Tue, 15 Oct 2019 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2 id="releasing-to-app-store">Releasing to App Store</h2>
<p>Getting your mobile app published in the App Store is simple in theory but tedious in practice. During the past nine months, I've sent several versions of the Nyxo app to be reviewed by Apple. Almost every time Apple has rejected a new version of the app, it has been because of some minor thing. That is why I decided to write a checklist for releasing your app to the App Store. Many of the items on this checklist can be automated using, for example, Fastlane, something I will write a separate setup guide and checklist later.</p>
<h2 id="before-bundling-the-app">Before bundling the app</h2>
<p>Increment the build number or number version
Because archiving the app takes some time, it's better to check this every time you start the archiving process.</p>
<ul>
<li>Check the app for crashes and bugs<!-- -->
<ul>
<li>Apple is, at times, very thorough in their app review. Check especially that your app works on iPad, even if you don't plan to support the device that is the device Apple uses for testing</li>
</ul>
</li>
<li>No broken links<!-- -->
<ul>
<li>If you're linking to content outside of your app, check that those links also work as Apple rarely checks them as well.</li>
</ul>
</li>
<li>Remove placeholder content</li>
<li>Check that your app doesn't mention 'beta' or any other words that could signal that the app is incomplete<!-- -->
<ul>
<li>Apple is very strict about this. Remember to also check everything</li>
</ul>
</li>
<li>Check permissions<!-- -->
<ul>
<li>Check that your info.plist includes all the permissions your app requires.</li>
</ul>
</li>
<li>Check localizations and translations<!-- -->
<ul>
<li>Check that you're not missing any translation key/values. Apple only tests the English version</li>
</ul>
</li>
</ul>
<h2 id="things-to-check-in-app-store-connect">Things to check in App Store Connect</h2>
<ul>
<li>Check app name and localizations<!-- -->
<ul>
<li>Check this especially if you've recently changed the name of the app in App Store in any way</li>
</ul>
</li>
<li>Check keywords and keyword localizations<!-- -->
<ul>
<li>It is good to update the keywords periodically, as it can potentially boost your App Store search hits a lot.</li>
</ul>
</li>
<li>Check in-app purchase sections<!-- -->
<ul>
<li>Check the names and descriptions of your in-app purchases</li>
<li>If you're offering subscriptions, remember to check that your app description contains a mention of these and the prices; otherwise, Apple will reject your app</li>
</ul>
</li>
<li>Check that your screenshots are up to date<!-- -->
<ul>
<li>If you've updated the UI of your app, always update the App Store screenshots as well.</li>
</ul>
</li>
<li>Check you app description text<!-- -->
<ul>
<li>Pay attention to the "text above the fold." Because the user has to click to expand it to see the rest of it.</li>
</ul>
</li>
<li>Check that your update text is compelling<!-- -->
<ul>
<li>"What's new" is one of the first things users see when they open your app's product page, which is why it's essential to make it highlight all the new features you added in this release.</li>
</ul>
</li>
</ul>
<h2 id="after-releasing-the-app">After releasing the app</h2>
<ul>
<li>Notify users that you've updated the app<!-- -->
<ul>
<li>I like to automate this part with IFTTT, which allows you to for example automatically tweet when the new version your app has been released</li>
</ul>
</li>
<li>Post to your channels and ask them to download/update to a new version of your app<!-- -->
<ul>
<li>Downloading the app can boost its position on Apple's chart, which is always good - Also don't be too shy to ask the people to know to rate your app, as it will improve its position in the Apple search</li>
</ul>
</li>
<li>Prepare for next the release<!-- -->
<ul>
<li>Ship early, ship often</li>
</ul>
</li>
</ul>
<p>These are the main things I check with every release. If you feel something should be included, leave a comment. You can also make you use of the checklist tool I've built for this, which allows you to actually check all the items on the list. You can find that <a href="https://perttu.dev/checklist">here</a>. I will write a similar checklist for the Google Play Store, as well. However, I will first need to get to know it better.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Build a clock face with SVG in React Native]]></title>
            <link>https://perttu.dev/articles/creating-a-clock-face-in-react-native-with-svg</link>
            <guid isPermaLink="false">https://perttu.dev/articles/creating-a-clock-face-in-react-native-with-svg</guid>
            <pubDate>Fri, 05 Jun 2020 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>In this article, we are going to look at how to draw a nice-looking analog clock face by using react-native, react-native-svg, and styled-components. The clock is going to tell time, tick, and have support for dark and light themes.</p>
<p>This is based on the work I’ve done on Nyxo, where we show a clock face as the base for displaying your sleep data. I’ve been getting some questions on how to create something similar, which is why I wrote this guide to help you create them yourselves. If you just want to get your hands on the code, <a href="https://github.com/plahteenlahti/HelloClock">it’s here.</a></p>
<h2 id="getting-started">Getting started</h2>
<p>Let's start by initializing a new project. You could very well do this with Expo, but I prefer to use the ejected version of React Native, so we are going to use the <code>react-native-cli</code> and the TypeScript example project to get started:</p>
<pre class="language-bash"><code class="language-bash">npx react-native init helloClock --template react-native-template-typescript
</code></pre>
<p>After that, let's install the only external library we need: <code>react-native-svg</code>.</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">npm</span> <span class="token function">install</span> react-native-svg
</code></pre>
<p>Then navigate to the <code>ios</code> folder and install the required pods:</p>
<pre class="language-bash"><code class="language-bash"><span class="token builtin class-name">cd</span> ios <span class="token operator">&amp;&amp;</span> pod <span class="token function">install</span> <span class="token operator">&amp;&amp;</span> <span class="token builtin class-name">cd</span> -
</code></pre>
<p>You can now run the project:</p>
<pre class="language-bash"><code class="language-bash">react-native run-ios
</code></pre>
<h2 id="folder-structure">Folder structure</h2>
<p>I'm going to structure the project in the following way:</p>
<pre><code>HelloClock
├── index.js
├── App.tsx
├── components
│   ├── Hand.tsx
│   ├── ClockMarkings.tsx
│   └── Clock.tsx
├── helpers
│   ├── geometry.ts
│   ├── time.ts
│   └── useInterval.ts
</code></pre>
<h2 id="polar-and-cartesian-coordinates">Polar and Cartesian Coordinates</h2>
<p>Now to the bread and butter of this article: how to convert time to coordinates on SVG.</p>
<p>We can think of clock times in degrees, i.e., 12 am and 12 pm being the same as 0°, and 6 pm and 6 am being 180°. We could, of course, use radians as well, but degrees feel more familiar to most people. A coordinate system that uses an angle and a reference point to determine a point on a plane is called the <a href="https://en.wikipedia.org/wiki/Polar_coordinate_system">Polar coordinate system</a>.</p>
<p>Converting time to Polar coordinate systems is relatively simple. For example, to determine the angle of the minute hand on a clock in degrees when the number of minutes is 30: if one full revolution is 60 minutes and one complete revolution is 360°, then dividing 30 minutes by 60 and multiplying that by 360 gives the same number of minutes in degrees, which is 180°. Let's implement that in the <code>geometry.ts</code> file:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword module">export</span> <span class="token keyword">function</span> <span class="token function">polarToCartesian</span><span class="token punctuation">(</span>
  centerX<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">,</span>
  centerY<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">,</span>
  radius<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">,</span>
  angleInDegrees<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">,</span>
<span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> angleInRadians <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">(</span>angleInDegrees <span class="token operator">-</span> <span class="token number">90</span><span class="token punctuation">)</span> <span class="token operator">*</span> <span class="token known-class-name class-name">Math</span><span class="token punctuation">.</span><span class="token constant">PI</span><span class="token punctuation">)</span> <span class="token operator">/</span> <span class="token number">180.0</span><span class="token punctuation">;</span>

  <span class="token keyword control-flow">return</span> <span class="token punctuation">{</span>
    x<span class="token operator">:</span> centerX <span class="token operator">+</span> radius <span class="token operator">*</span> <span class="token known-class-name class-name">Math</span><span class="token punctuation">.</span><span class="token method function property-access">cos</span><span class="token punctuation">(</span>angleInRadians<span class="token punctuation">)</span><span class="token punctuation">,</span>
    y<span class="token operator">:</span> centerY <span class="token operator">+</span> radius <span class="token operator">*</span> <span class="token known-class-name class-name">Math</span><span class="token punctuation">.</span><span class="token method function property-access">sin</span><span class="token punctuation">(</span>angleInRadians<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>
<h2 id="building-the-clock-component">Building the Clock Component</h2>
<p>Let’s start the UI work by cleaning up the <code>App.tsx</code> file so that it only includes a <code>StatusBar</code>, <code>SafeAreaView</code>, and the <code>&lt;Clock&gt;</code> component:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token comment">// App.tsx</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">React</span></span> <span class="token keyword module">from</span> <span class="token string">"react"</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">Clock</span></span> <span class="token keyword module">from</span> <span class="token string">"./components/Clock"</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports">styled</span> <span class="token keyword module">from</span> <span class="token string">"styled-components/native"</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">App</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token operator">&lt;</span><span class="token operator">&gt;</span>
      <span class="token operator">&lt;</span><span class="token maybe-class-name">StatusBar</span> barStyle<span class="token operator">=</span><span class="token string">"light-content"</span> <span class="token operator">/</span><span class="token operator">&gt;</span>
      <span class="token operator">&lt;</span><span class="token maybe-class-name">SafeAreaView</span><span class="token operator">&gt;</span>
        <span class="token operator">&lt;</span><span class="token maybe-class-name">ScrollView</span>
          centerContent<span class="token operator">=</span><span class="token punctuation">{</span><span class="token boolean">true</span><span class="token punctuation">}</span>
          contentInsetAdjustmentBehavior<span class="token operator">=</span><span class="token string">"automatic"</span><span class="token operator">&gt;</span>
          <span class="token operator">&lt;</span><span class="token maybe-class-name">Clock</span> <span class="token operator">/</span><span class="token operator">&gt;</span>
        <span class="token operator">&lt;</span><span class="token operator">/</span><span class="token maybe-class-name">ScrollView</span><span class="token operator">&gt;</span>
      <span class="token operator">&lt;</span><span class="token operator">/</span><span class="token maybe-class-name">SafeAreaView</span><span class="token operator">&gt;</span>
    <span class="token operator">&lt;</span><span class="token operator">/</span><span class="token operator">&gt;</span>
  <span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> <span class="token maybe-class-name">ScrollView</span> <span class="token operator">=</span> styled<span class="token punctuation">.</span><span class="token property-access"><span class="token maybe-class-name">ScrollView</span></span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token css language-css">
  <span class="token property">flex</span><span class="token punctuation">:</span> <span class="token number">1</span><span class="token punctuation">;</span>
  <span class="token property">background-color</span><span class="token punctuation">:</span> <span class="token color">black</span><span class="token punctuation">;</span>
</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">;</span>

<span class="token keyword">const</span> <span class="token maybe-class-name">SafeAreaView</span> <span class="token operator">=</span> styled<span class="token punctuation">.</span><span class="token property-access"><span class="token maybe-class-name">SafeAreaView</span></span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token css language-css">
  <span class="token property">background-color</span><span class="token punctuation">:</span> <span class="token color">black</span><span class="token punctuation">;</span>
  <span class="token property">flex</span><span class="token punctuation">:</span> <span class="token number">1</span><span class="token punctuation">;</span>
</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">;</span>

<span class="token keyword">const</span> <span class="token maybe-class-name">StatusBar</span> <span class="token operator">=</span> styled<span class="token punctuation">.</span><span class="token property-access"><span class="token maybe-class-name">StatusBar</span></span><span class="token punctuation">.</span><span class="token method function property-access">attrs</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span><span class="token punctuation">{</span>
  barStyle<span class="token operator">:</span> <span class="token string">"light-content"</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">;</span>

<span class="token keyword module">export</span> <span class="token keyword module">default</span> <span class="token maybe-class-name">App</span><span class="token punctuation">;</span>
</code></pre>
<p>Next, we’ll work on the Clock, ClockMarkings, and Hand components. In <code>Clock.tsx</code>, import <code>Dimensions</code> from <code>react-native</code> and the <code>Svg</code> component from <code>react-native-svg</code>. Make the <code>Clock</code> component return a square SVG with the side of the square being the same as the width of the mobile phone's screen using the <code>Dimensions</code> helper:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token comment">// Clock.tsx</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">React</span></span> <span class="token keyword module">from</span> <span class="token string">"react"</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">Svg</span></span> <span class="token keyword module">from</span> <span class="token string">"react-native-svg"</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> <span class="token maybe-class-name">Dimensions</span> <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">"react-native"</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> <span class="token punctuation">{</span> width <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token maybe-class-name">Dimensions</span><span class="token punctuation">.</span><span class="token method function property-access">get</span><span class="token punctuation">(</span><span class="token string">"window"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">Clock</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token operator">&lt;</span><span class="token maybe-class-name">Svg</span> height<span class="token operator">=</span><span class="token punctuation">{</span>width<span class="token punctuation">}</span> width<span class="token operator">=</span><span class="token punctuation">{</span>width<span class="token punctuation">}</span><span class="token operator">&gt;</span><span class="token operator">&lt;</span><span class="token operator">/</span><span class="token maybe-class-name">Svg</span><span class="token operator">&gt;</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>

<span class="token keyword module">export</span> <span class="token keyword module">default</span> <span class="token maybe-class-name">Clock</span><span class="token punctuation">;</span>
</code></pre>
<p>When saved, nothing will appear on the screen since SVG itself has no visible parts. Let’s continue by adding the ClockMarkings to communicate the minutes and hours. First, define how many ticks we want by writing these values above the Clock component:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token comment">// Clock.tsx</span>
<span class="token keyword">const</span> diameter <span class="token operator">=</span> width <span class="token operator">-</span> <span class="token number">40</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> center <span class="token operator">=</span> width <span class="token operator">/</span> <span class="token number">2</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> radius <span class="token operator">=</span> diameter <span class="token operator">/</span> <span class="token number">2</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> hourStickCount <span class="token operator">=</span> <span class="token number">12</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> minuteStickCount <span class="token operator">=</span> <span class="token number">12</span> <span class="token operator">*</span> <span class="token number">6</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">Clock</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token operator">&lt;</span><span class="token maybe-class-name">Svg</span> height<span class="token operator">=</span><span class="token punctuation">{</span>width<span class="token punctuation">}</span> width<span class="token operator">=</span><span class="token punctuation">{</span>width<span class="token punctuation">}</span><span class="token operator">&gt;</span><span class="token operator">&lt;</span><span class="token operator">/</span><span class="token maybe-class-name">Svg</span><span class="token operator">&gt;</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>

<span class="token keyword module">export</span> <span class="token keyword module">default</span> <span class="token maybe-class-name">Clock</span><span class="token punctuation">;</span>
</code></pre>
<p>Now let’s create the ClockMarkings component in <code>ClockMarkings.tsx</code> and render the hour and minute ticks:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token comment">// ClockMarkings.tsx</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">React</span></span> <span class="token keyword module">from</span> <span class="token string">"react"</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token punctuation">{</span> <span class="token constant">G</span><span class="token punctuation">,</span> <span class="token maybe-class-name">Line</span><span class="token punctuation">,</span> <span class="token maybe-class-name">Text</span> <span class="token punctuation">}</span> <span class="token keyword module">from</span> <span class="token string">"react-native-svg"</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> polarToCartesian <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">"../helpers/geometry"</span><span class="token punctuation">;</span>

<span class="token keyword">type</span> <span class="token class-name"><span class="token maybe-class-name">Props</span></span> <span class="token operator">=</span> <span class="token punctuation">{</span>
  radius<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">;</span>
  center<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">;</span>
  minutes<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">;</span>
  hours<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">ClockMarkings</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span>props<span class="token operator">:</span> <span class="token maybe-class-name">Props</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">{</span> radius<span class="token punctuation">,</span> center<span class="token punctuation">,</span> minutes<span class="token punctuation">,</span> hours <span class="token punctuation">}</span> <span class="token operator">=</span> props<span class="token punctuation">;</span>
  <span class="token keyword">const</span> minutesArray <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Array</span></span><span class="token punctuation">(</span>minutes<span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">fill</span><span class="token punctuation">(</span><span class="token number">1</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">const</span> hoursArray <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Array</span></span><span class="token punctuation">(</span>hours<span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">fill</span><span class="token punctuation">(</span><span class="token number">1</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

  <span class="token keyword">const</span> minuteSticks <span class="token operator">=</span> minutesArray<span class="token punctuation">.</span><span class="token method function property-access">map</span><span class="token punctuation">(</span><span class="token punctuation">(</span>_<span class="token punctuation">,</span> index<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
    <span class="token keyword">const</span> start <span class="token operator">=</span> <span class="token function">polarToCartesian</span><span class="token punctuation">(</span>center<span class="token punctuation">,</span> center<span class="token punctuation">,</span> radius<span class="token punctuation">,</span> index <span class="token operator">*</span> <span class="token number">5</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token keyword">const</span> end <span class="token operator">=</span> <span class="token function">polarToCartesian</span><span class="token punctuation">(</span>center<span class="token punctuation">,</span> center<span class="token punctuation">,</span> radius<span class="token punctuation">,</span> index <span class="token operator">*</span> <span class="token number">5</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
      <span class="token operator">&lt;</span><span class="token maybe-class-name">Line</span>
        stroke<span class="token operator">=</span><span class="token string">"white"</span>
        strokeWidth<span class="token operator">=</span><span class="token punctuation">{</span><span class="token number">2</span><span class="token punctuation">}</span>
        strokeLinecap<span class="token operator">=</span><span class="token string">"round"</span>
        key<span class="token operator">=</span><span class="token punctuation">{</span>index<span class="token punctuation">}</span>
        x1<span class="token operator">=</span><span class="token punctuation">{</span>start<span class="token punctuation">.</span><span class="token property-access">x</span><span class="token punctuation">}</span>
        x2<span class="token operator">=</span><span class="token punctuation">{</span>end<span class="token punctuation">.</span><span class="token property-access">x</span><span class="token punctuation">}</span>
        y1<span class="token operator">=</span><span class="token punctuation">{</span>start<span class="token punctuation">.</span><span class="token property-access">y</span><span class="token punctuation">}</span>
        y2<span class="token operator">=</span><span class="token punctuation">{</span>end<span class="token punctuation">.</span><span class="token property-access">y</span><span class="token punctuation">}</span>
      <span class="token operator">/</span><span class="token operator">&gt;</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 keyword">const</span> hourSticks <span class="token operator">=</span> hoursArray<span class="token punctuation">.</span><span class="token method function property-access">map</span><span class="token punctuation">(</span><span class="token punctuation">(</span>_<span class="token punctuation">,</span> index<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
    <span class="token keyword">const</span> start <span class="token operator">=</span> <span class="token function">polarToCartesian</span><span class="token punctuation">(</span>center<span class="token punctuation">,</span> center<span class="token punctuation">,</span> radius <span class="token operator">-</span> <span class="token number">10</span><span class="token punctuation">,</span> index <span class="token operator">*</span> <span class="token number">30</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token keyword">const</span> end <span class="token operator">=</span> <span class="token function">polarToCartesian</span><span class="token punctuation">(</span>center<span class="token punctuation">,</span> center<span class="token punctuation">,</span> radius<span class="token punctuation">,</span> index <span class="token operator">*</span> <span class="token number">30</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token keyword">const</span> time <span class="token operator">=</span> <span class="token function">polarToCartesian</span><span class="token punctuation">(</span>center<span class="token punctuation">,</span> center<span class="token punctuation">,</span> radius <span class="token operator">-</span> <span class="token number">35</span><span class="token punctuation">,</span> index <span class="token operator">*</span> <span class="token number">30</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

    <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
      <span class="token operator">&lt;</span><span class="token constant">G</span> key<span class="token operator">=</span><span class="token punctuation">{</span>index<span class="token punctuation">}</span><span class="token operator">&gt;</span>
        <span class="token operator">&lt;</span><span class="token maybe-class-name">Line</span>
          stroke<span class="token operator">=</span><span class="token string">"white"</span>
          strokeWidth<span class="token operator">=</span><span class="token punctuation">{</span><span class="token number">3</span><span class="token punctuation">}</span>
          strokeLinecap<span class="token operator">=</span><span class="token string">"round"</span>
          x1<span class="token operator">=</span><span class="token punctuation">{</span>start<span class="token punctuation">.</span><span class="token property-access">x</span><span class="token punctuation">}</span>
          x2<span class="token operator">=</span><span class="token punctuation">{</span>end<span class="token punctuation">.</span><span class="token property-access">x</span><span class="token punctuation">}</span>
          y1<span class="token operator">=</span><span class="token punctuation">{</span>start<span class="token punctuation">.</span><span class="token property-access">y</span><span class="token punctuation">}</span>
          y2<span class="token operator">=</span><span class="token punctuation">{</span>end<span class="token punctuation">.</span><span class="token property-access">y</span><span class="token punctuation">}</span>
        <span class="token operator">/</span><span class="token operator">&gt;</span>
        <span class="token operator">&lt;</span><span class="token maybe-class-name">Text</span>
          textAnchor<span class="token operator">=</span><span class="token string">"middle"</span>
          fontSize<span class="token operator">=</span><span class="token string">"17"</span>
          fontWeight<span class="token operator">=</span><span class="token string">"bold"</span>
          fill<span class="token operator">=</span><span class="token string">"white"</span>
          alignmentBaseline<span class="token operator">=</span><span class="token string">"central"</span>
          x<span class="token operator">=</span><span class="token punctuation">{</span>time<span class="token punctuation">.</span><span class="token property-access">x</span><span class="token punctuation">}</span>
          y<span class="token operator">=</span><span class="token punctuation">{</span>time<span class="token punctuation">.</span><span class="token property-access">y</span><span class="token punctuation">}</span><span class="token operator">&gt;</span>
          <span class="token punctuation">{</span>index <span class="token operator">===</span> <span class="token number">0</span> <span class="token operator">?</span> <span class="token number">12</span> <span class="token operator">:</span> index<span class="token punctuation">}</span>
        <span class="token operator">&lt;</span><span class="token operator">/</span><span class="token maybe-class-name">Text</span><span class="token operator">&gt;</span>
      <span class="token operator">&lt;</span><span class="token operator">/</span><span class="token constant">G</span><span class="token operator">&gt;</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 keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token operator">&lt;</span><span class="token constant">G</span><span class="token operator">&gt;</span>
      <span class="token punctuation">{</span>minuteSticks<span class="token punctuation">}</span>
      <span class="token punctuation">{</span>hourSticks<span class="token punctuation">}</span>
    <span class="token operator">&lt;</span><span class="token operator">/</span><span class="token constant">G</span><span class="token operator">&gt;</span>
  <span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>

<span class="token keyword module">export</span> <span class="token keyword module">default</span> <span class="token maybe-class-name">ClockMarkings</span><span class="token punctuation">;</span>
</code></pre>
<p>Now if we add the ClockMarkings component to the Clock component and pass the variables for the clock face, we get a basic clock face.</p>
<h3 id="adding-alt-texts">Adding alt texts</h3>
<p>In the article's images, provide meaningful <code>alt</code> attributes for better accessibility:</p>
<pre class="language-html"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>Image</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span>{ClockMarkings}</span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>Illustration of clock markings showing hours and minutes<span class="token punctuation">"</span></span> <span class="token punctuation">/&gt;</span></span>
</code></pre>
<pre class="language-html"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>Image</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span>{Clock}</span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>Completed clock face with hands showing time<span class="token punctuation">"</span></span> <span class="token punctuation">/&gt;</span></span>
</code></pre>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Critique of the Bayesian brain hypothesis]]></title>
            <link>https://perttu.dev/articles/critique-of-the-bayesian-brain-hypothesis</link>
            <guid isPermaLink="false">https://perttu.dev/articles/critique-of-the-bayesian-brain-hypothesis</guid>
            <pubDate>Wed, 10 Mar 2021 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Human behavior is driven by incomplete data and methods to turn that data into action and perception. Take, for example, our eyes, which do not actually construct a complete high-resolution picture of our surroundings despite that we experience it so. Instead, that image is filled with noise, missing large chunks, and top of all also inverted. Yet, we can use this incomplete data to navigate the world and successfully perform complex behaviors in it. Based on research in cognitive science, it has been theorized that this results from organisms' innate ability to carry out statistical inference. This hypothesis is commonly referred to as the Bayesian brain hypothesis, and the theories concerning posit that our minds and brains are near-optimal in solving a variety of tasks (Bower and Davis 2012).</p>
<p>As mentioned by Bowers and Davis (2012), this conclusion that human behavior is close to the Bayesian optimal is exciting as it positions our behavior in a surprising and counterintuitive state of actually being optimal. They continue that this surprising claim has gathered a wide array of research during the years but not that much criticism as highlighted by a 2013 paper by Marcus and Davis.</p>
<p>This article aims to collect and review the criticism surrounding the Bayesian approach to the brain. The first part will focus on further introducing the concept of Bayesian inference in the brain. The later parts will aim to review and provide a concise framework of literature on criticism surrounding the Bayesian brain hypothesis.</p>
<h2 id="introduction-to-bayesian-theory">Introduction to Bayesian theory</h2>
<p>Before further analysis of the criticism concerning the Bayesian brain hypothesis, it is worthwhile to cover the fundamentals of both Bayesian statistics as well as their application in cognitive science and neuroscience.</p>
<h3 id="bayesian-probability">Bayesian probability</h3>
<p>In the Bayesian inference models of cognition, cognition is viewed as a process of drawing inferences from observed data (Marcus and Davis 2013). This inference is governed by Bayes's theorem, where A is the hypotheses being investigated, and B is the data that has been observed then states that for every hypothesis,</p>
<p>In this equation, P(Aᵢ| B) is the posterior probability of hypothesis Aᵢ when data B has been observed. P(Aᵢ) is the prior probability of A being true before observing any data, and P(B|Aᵢ) is the likelihood, that is, the conditional probability that B has been observed when Aᵢ is true. This theorem then states that the posterior probability is proportional to the product of prior probability and prior probabilities. In research, the data is usually information available to the human reasoner, priors are the initial state of knowledge, and hypotheses are the conclusion the reasoner draws from these (Bowers and Davis, 2012).</p>
<h3 id="bayesian-brain-hypothesis">Bayesian brain hypothesis</h3>
<p>On the most general level, probabilistic theories of the mind, such as the Bayesian theories in cognitive psychology, make the assumption that brains perform statistical inference on noisy and ambiguous information (Bowers and Davis, 2012). Most Bayesian theories describe the goal of the computation, why that computation is appropriate, and the logic of the strategy. What they don't take into consideration are the mental presentation and processes employed in solving the task. The fundamental goal of the approach is to determine what the optimal solution should look like, and use that to provide important constraints on theories of the brain (Bowers and Davis 2012).</p>
<p>At the same time that the Bayesian approach has gathered interest in cognitive science, the Bayesian approach has also become prominent in neuroscience (Bowers and Davis, 2012). The general idea behind the theories in neuroscience that employ the Bayesian approach is that populations of neurons are capable of representing uncertainty in the form of probability distributions and also capable of performing Bayesian computations (Bowers and Davis, 2012). The criticism surrounding this claim is inspected later in this paper.</p>
<h2 id="criticism-surrounding-bayesian-methodology">Criticism surrounding Bayesian methodology</h2>
<h3 id="criticism-on-post-hoc-practices">Criticism on post hoc practices</h3>
<p>A notable criticism surrounding Bayesian inference in psychology comes from the appearance of post hoc practices in studies that have aimed to show the mind as a near-optimal engine of probabilistic inference. In their 2013 paper, Marcus and Davis name two closely related problems: task selection and modal selection, as a reason for concern. They see that these two problems undermine the conclusion of cognition being optimal or driven by probabilistic inference.</p>
<h3 id="criticism-of-task-selection">Criticism of task selection</h3>
<p>According to Marcus and Davis (2013), human cognition can seem near-normative when the circumstances are correct while failing to appear so in others. They use a study of physical reasoning by Battaglia, Hamrick, and Tenenbaum as an example (Battaglia et al., 2013). In this study, participants were asked to predict how a tower of blocks would fall. Based on these results, the researchers proposed that humans' physical judgments are a form of probabilistic inference. However, according to Marcus and Davis, a substantial amount of previous studies have shown different results for other physical judgments. The aforementioned example highlights how research surrounding Bayesian inference suffers from generalizability: models fit a subset of tasks of the same problem space. According to Marcus and Davis, this could be the result of probabilistic-cognition literature disproportionately reporting success leading to a distorted perception of the applicability of these approaches.</p>
<p>Marcus and Davis continue that this risk of favoring probabilistic theories of cognition is elevated by the practice of selecting from a broader range of domains instead of diving deeper into one challenging domain. They conclude their criticism of task selection in Bayesian inference by proposing that an attempt to understand the competing mechanisms would possibly be a more enlightening approach.</p>
<h3 id="criticism-of-model-selection">Criticism of model selection</h3>
<p>Related to the problem of task selection is the problem of selecting the models. Every model depends heavily on the choice of probabilities which can come from real-world frequencies, experimental subjects' judgments, and mathematical models (Marcus and Davis, 2013). In addition to the selection of probabilities, a number of other parameters need to be set either by basing the model on real-world statistics; by choosing the model or tuning the parameters to better fit the experiment at hand, or by using purely theoretical considerations, which can sometimes be quite arbitrary.</p>
<p>Each of these choices for constructing a model can become problematic for the model's real applicability (Marcus and Davis, 2013). Real-world frequencies, for example, maybe heavily dependent on what dataset is used. Another subject of problems is how the fit of the model to a certain data set is contingent on how the priors are chosen. To highlight this, Marcus and Davis use Griffiths and Tenenbaum's (2006) study where participants were asked to predict the length of poems, of which they were read a small section and then provide the information on what line that was. The priors for this study were based on the distribution of poem lengths in an online corpus of poetry. The results of the study made Griffith and Tenenbaum suggest that "people's judgements for . . . poem lengths . . . were indistinguishable from optimal Bayesian predictions based on the empirical prior distributions". However, Marcus and Davis (2013) see that the actual fit and the model was actually not in fact as close as the study had suggested, as "it requires no great knowledge of poetry to predict that a poem whose fifth line has been quoted must have at least five lines". The actual fit of the model was overestimated because of how priors were chosen.</p>
<p>Marcus and Davis (2013) conclude their criticism by arguing that probabilistic models such as the Bayesian approach have not yielded a robust account of cognition that would be applicable across tasks. Yet, they also see the Bayesian approach as a useful tool, albeit not one that one-size-fits-all solution.</p>
<h2 id="bayesian-theories-against-traditional-non-bayesian-approaches">Bayesian theories against traditional non-Bayesian approaches</h2>
<p>While Marcus and Davis' (2013) challenge the robustness of the Bayesian approach and how models and tasks are selected, it does not contrast the probabilistic approach to other approaches of studying the brain and mind. How promising the Bayesian approach is when compared to more traditional non-Bayesian approaches to studying mind and brain is discussed in Bowers and Davis' 2012 paper.</p>
<p>Bower and Davis (2012) argue that the evidence supporting the Bayesian approach is rather weak, with proponents of the Bayesian approach having reached quite different conclusions on the basis of the same evidence. Similar to Marcus and Davis (2013), Bowers and Davis also see that the selection of priors and likelihoods can arbitrarily be altered to provide a better fit, providing an additional point that it makes Bayesian models difficult to falsify. The paper also argues that the Bayesian approach is too rarely compared to the non-Bayesian approaches, such as heuristic or adaptive theories of the mind. As the last major point of critique, Bowers and Davis argue that there is very little data for supporting the notion of collections of neurons performing Bayesian computations.</p>
<p>Bowers and Davis (2012) conclude their paper by arguing that it is not clear how the Bayesian approach provides additional insights into the nature of mind and brain when compared to non-Bayesian approaches. They instead see that the result of the Bayesian approach has been a collection of Bayesian just-so stories, in which mathematical analyses can be used to explain almost any behavior as optimal.</p>
<h2 id="criticism-against-bayesian-theorizing">Criticism against Bayesian theorizing</h2>
<p>Equally strong criticism has been raised on how Bayesian approaches are used to theorize in cognitive science. In their 2011 article, Jones and Love (2011) subdivide Bayesian theories into two philosophies: Bayesian fundamentalism and Bayesian enlightenment. Bayesian fundamentalism firmly clings to the principle that human behavior is explainable through rational analysis: once a given task is correctly reduced to environmental statistics and goals, human behavior in that task will be found to be rational. This approach places too much emphasis on the mathematical and computational power of probabilistic inference, without moving towards more substantial theoretical development (Jones and Love, 2011). According to Jones and Love (2011), most of the research surrounding the Bayesian approach falls into the category of Bayesian fundamentalism.</p>
<p>In contrast to Bayesian fundamentalism, Jones and Love's Bayesian enlightenment goes beyond the dogma of rational analysis, actively integrating with other avenues or inquiries in cognitive science (Jones and Love, 2011). They see this as an approach that does aim towards more substantial theoretical development.</p>
<p>Jones and Love's (2011) view the Enlightened Bayesian view as being capable of taking the more interesting aspect of the Bayesian approach; which include the algorithms by which inference is carried out and the representations on which those algorithms operate; seriously as psychological constructs and evaluate them according to theoretical merit rather than mathematical convenience. They conclude their criticism on Bayesian theorizing by predicting that the Bayesian approach has much to contribute as long as it is developed in a way that does not eliminate the psychology from psychological models.</p>
<h2 id="conclusion">Conclusion</h2>
<p>The probabilistic approach to the brain can be seen as a widely applicable approach, but one of which research and results need further inspection and review, as can be concluded from the criticism reviewed in this paper. The criticism has primarily focused on how the selection of models and tasks has contributed to overpromising models. Another large part of criticism has been the subject of how Bayesian methods are used to theorize in cognitive science. Despite the reviewed criticisms, the Bayesian approach is seen as a promising approach, as long as the limitations of it are understood and it continues moving towards more substantial theoretical development.</p>
<h2 id="references">References</h2>
<ul>
<li>
<p>Battaglia, P. W., Hamrick, J. B., &amp; Tenenbaum, J. B. (2013). Simulation as an engine of physical scene understanding. <em>Proceedings of the National Academy of Sciences</em>, 110(45), 18327–18.</p>
</li>
<li>
<p>Bowers, J. S., &amp; Davis, C. J. (2012). Bayesian just-so stories in psychology and neuroscience. <em>Psychological Bulletin</em>, 138(3), 389.</p>
</li>
<li>
<p>Jones, M., &amp; Love, B. C. (2011). Bayesian fundamentalism or enlightenment? On the explanatory status and theoretical contributions of Bayesian models of cognition. <em>Behavioral and brain sciences</em>, 34(4), 169.</p>
</li>
<li>
<p>Griffiths, T. L., &amp; Tenenbaum, J. B. (2006). Optimal predictions in everyday cognition. <em>Psychological science</em>, 17(9), 767–773.</p>
</li>
<li>
<p>Marcus, G. F., &amp; Davis, E. (2013). How robust are probabilistic models of higher-level cognition?. <em>Psychological science</em>, 24(12), 2351–2360.</p>
</li>
</ul>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Crosstalk in stereoscopic displays - why 3D movies look weird]]></title>
            <link>https://perttu.dev/articles/crosstalk-in-stereoscopic-displays</link>
            <guid isPermaLink="false">https://perttu.dev/articles/crosstalk-in-stereoscopic-displays</guid>
            <pubDate>Wed, 12 Jun 2019 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Remember 3D TVs? Remember how the TV manufacturers tried to ship the idea of wearing goggles at home while watching TV, so you could experience that Avatar-style novelty from the comfort of your own couch? Although the idea 3D television is pretty much dead now, and everyone is more focused on the possibilities of AR and VR, I would not call 3D a dead technology yet. After all, cinemas are still pushing 3D movies and if we eventually crack the method for building glassless 3D televisions we could maybe even see the second coming of 3D at home. 3D has just been forgotten, a faith it has endured once before as well.</p>
<p>This article is not however so much about the future of 3D, but instead about an interesting phenomenon in human perception caused by 3D displays called <strong>crosstalk</strong>. Many of us have most likely encountered crosstalk at least once in our lives, but due to the</p>
<h4 id="introduction--crosstalk-and-technological-context">Introduction – Crosstalk and Technological Context</h4>
<p>Stereoscopic three-dimensional (3D) screens are generally better at creating a greater immersion with the displayed content, thus enabling richer experiences. The difference in immersion compared to more traditional two-dimensional screens (2D), comes with the help of binocular depth. However, 3D dimensional displays do not actually display content in three-dimensional space, but instead rely on to projection of different image to each of the eyes, they have a possible problem of inducing various visual artifacts that cause discomfort and degradation of the <em>Quality of Experience</em>. One of these visual artifacts is <strong><em>crosstalk</em></strong>, an effect caused by another eye’s half-stereoscopic image bleeding into the other eye. This paper will focus on explaining the causes of crosstalk and how the human visual system understands it by concentrating on literate regarding crosstalk. A couple of different technologies used to create three-dimensionality will be inspected on their relation to crosstalk and the other definitions of crosstalk. This article will also look at studies conducted on measuring crosstalk, the perceived experience of crosstalk on different people, and the different levels of crosstalk. I will conclude this essay by introducing methods and ways of combating crosstalk in 3D displays.</p>
<p>But what is crosstalk then, and what causes it? To understand crosstalk phenomena, we have to first understand how 3D videos are created and how they can be viewed on different display technologies. The basic structure of creating 3D video is to use two cameras when filming a scene either by using two real cameras or by using two artificial cameras, a standard method in digital animation. This leads to having two different recordings of the same scene, but from a slightly different angle. This is mostly the same as how human eyes work, we are actually seeing two different views of the same world, which are eventually combined into a single image through stereopsis. To create the illusion of two different views and the illusion of depth on a display, the display has to show different views on top of each other. The key technologies of 3D displays have to do with combating this problem.</p>
<p>Understanding the technologies used to achieve the 3D effect is vital as crosstalk differs between different technologies. The difference of crosstalk on different technologies is inspected more closely later; however, this is done to a limited degree. One of the most well known 3D technologies is called anaglyphic 3D, which uses color filters in displaying different view to each eye. The filtering is usually achieved by wearing a pair of glasses with red/green filters on them. Another similar but more widely used method is using polarized filters (polarization-multiplexing) to filter the correct image to each eye. This and the former are both method cheap and achievable with passive, no electricity using glasses. There are also active methods, such as using an eclipsing method of blocking the view of the other eye (time-multiplexing), and interference technology of displaying two different images with two different wavelengths of light. Worth mentioning is also the possibility of autostereoscopic displays, which enable stereoscopic viewing without glasses.</p>
<h4 id="definition-of-crosstalk">Definition of Crosstalk</h4>
<p>Now that we understand the technological context, explanation of crosstalk is more tangible. Crosstalk is essentially produced by imperfect view separation, leading to a proportion of one eye’s image to be seen by the other eye as well (<a href="https://ieeexplore.ieee.org/abstract/document/6051494">Xing 2012</a>). This definition is good enough for communicating the general concept of crosstalk. However, for scientific conversation, it is much too ill-defined. In the following parts, the term and definition of crosstalk are subject to closer inspection.</p>
<p>Well known and widely used in the literature of stereoscopic displays, crosstalk as an effect is also known by other names and spellings (<a href="https://www.spiedigitallibrary.org/conference-proceedings-of-spie/7863/78630Z/How-are-crosstalk-and-ghosting-defined-in-the-stereoscopic-literature/10.1117/12.877045.short?SSO=1">Woods 2011</a>) such as cross talk, cross-talk, leakage, extinction, extinction ratio, 3D contrast, and even x-talk. In addition to these the term ‘ghosting’ is often used interchangeably, but according to Woods, the first separate definition for ghosting comes from Lipton in 1987 <a href="https://www.spiedigitallibrary.org/conference-proceedings-of-spie/0761/0000/Factors-Affecting-Ghosting-In-Time-Multiplexed-Piano-Stereoscopic-Crt-Display/10.1117/12.940123.short">(Lipton 1987</a>, <a href="https://www.spiedigitallibrary.org/conference-proceedings-of-spie/7863/78630Z/How-are-crosstalk-and-ghosting-defined-in-the-stereoscopic-literature/10.1117/12.877045.short?SSO=1">Woods 2011</a>). Lipton’s definition of ghosting and crosstalk are refined in a publication from 2009, where crosstalk is “incomplete isolation of the left and right image channels so that one leaks or bleed into the other” and ghosting is defined as being the subjective perception of crosstalk” (<a href="https://lennylipton.wordpress.com/2009/03/16/glossary/">Lipton 2009</a>). Woods also points out a definition made by Huang et al. in which <em>System crosstalk</em> describes crosstalk, while <em>Viewer crosstalk</em> is used to address the subjective experience of it, i.e. ghosting (<a href="https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.165.5355&amp;rep=rep1&amp;type=pdf">Woods 2010</a>).</p>
<p>To be used as a metric, crosstalk has to be defined mathematically. Woods et al. mention in their article that there exist two different mathematical definitions for ‘crosstalk ratio’ (<a href="https://www.spiedigitallibrary.org/conference-proceedings-of-spie/7863/78630Z/How-are-crosstalk-and-ghosting-defined-in-the-stereoscopic-literature/10.1117/12.877045.short?SSO=1">Woods 2011</a>). They also point out that unfortunately, there exist several papers quoting crosstalk values, without specifying which definition is used. This serves to strengthen the need for a standardized definition of crosstalk ratio.</p>
<p>Woods et al. define crosstalk in its simplest form as a function of leakage divided by signal multiplied by hundred (<a href="https://www.spiedigitallibrary.org/conference-proceedings-of-spie/7863/78630Z/How-are-crosstalk-and-ghosting-defined-in-the-stereoscopic-literature/10.1117/12.877045.short?SSO=1">Woods 2011</a>), illustrated in the formula below.</p>
<p>In this formula, leakage means the maximum luminance of light that leaks from the unintended channel to the intended channel, and signal means the maximum luminance of the intended channel. The measurements for luminance are achieved by measuring the level of black in the intended channel and the white in the unintended channel, which corresponds with leakage. In the same manner, signal correspondence is achieved by measuring the level of white in the intended channel and the level of black in the unintended channel.</p>
<p>Woods et al. also point out two other mathematical definitions, which agree with this basic definition; however, for the sake of this paper’ scope, those definitions are not presented here. These two definitions can be found from articles by Chu et al. and Hong et al. (Chu 2005, Hong 2010). However because the earlier mathematical description of does not take into consideration the effect of black level, Woods et al. introduce another mathematical definition where the non-zero black level is taken into account by subtracting the black level luminance, all illustrated below.</p>
<h4 id="technological-factors-affecting-crosstalk">Technological Factors Affecting Crosstalk</h4>
<p>Different 3D inducing technologies have different levels of crosstalk as crosstalk exists in almost all stereoscopic displays, an exception to this being the ones using Wheatstone and Helmholtz approach, which however do not conform to today’s requirement of flat-panel display and are therefore left out of the scope of this paper (Daly 2011). Nevertheless, crosstalk is a primary concern in developing stereoscopic display systems (Daly 2011).</p>
<p>One technology that is especially prone the crosstalk is anaglyphic 3D, whose main benefits are simplicity and cost. However, anaglyphic 3D’s main disadvantage in addition to crosstalk is its inability to depict full-color images (Woods 2004). Anaglyphic 3D systems are much rarer in these days, as active time- and polarization-multiplexed approaches tend to work better with lesser quality control. The level of filtering in anaglyphic 3D systems can vary considerably between different manufactured glasses, resulting in varying amounts of crosstalk (Woods 2004). Woods et al. list properties that cause crosstalk in the anaglyphic 3D system in their 2004 paper. These are:</p>
<ul>
<li>Display spectral response – crosstalk is caused by the display’s primary color band overlap with each other significantly, making it difficult to separate those colors with color filters.</li>
<li>Anaglyph glasses spectral response – crosstalk is caused by color filters of the anaglyph glasses pass light that is from an undesired domain, for example when passing also higher wavelength light.</li>
<li>Image compression – crosstalk is caused by image compression formats (e.g. MPEG, JPEG) mixing information between the three RGB color channels.</li>
<li>Image encoding and transmission – crosstalk is caused by analog consumer video formats (NTSC, PAL) mixing RGB colors channels during encoding.</li>
</ul>
<p>Crosstalk is also present in other technologies. For example Lipton’s 1987 paper names two crosstalk factors for time-multiplexed stereoscopic systems, which are phosphor decay and the dynamic range of the shutters (Lipton 1987). Phosphor decay is an effect of phosphor after-image in liquid crystal based shutter glasses, this is common with many CRT based displays, where the image generation results in still decaying phosphor projection to leak into the other eye’s view. Another crosstalk factor, according to Lipton is the dynamic range of the glasses, meaning the ratio of the transmission of the glasses’ shutters in their opened state to their closed state. Problems with a dynamic range of shutters lead to wrong image being displayed for the wrong eye (Seuntiëns 2005).</p>
<p>In addition to the presented technologies, there are also other 3D technologies, where crosstalk exist. Similarly to anaglyphic and polarized 3D technologies, there are different factors between the technologies that have an effect on the amount of crosstalk. As a conclusion, it could be said that when designing a 3D technology-enabled experiences a decent amount of research should be dedicated to understanding the factors affecting the amount of crosstalk on that current approach. This statement is further strengthened by a statement found in 3D literature of that crosstalk is intrinsic to most of the technologies (Seuntiëns 2005).</p>
<h4 id="crosstalks-effect-on-viewer-experience-and-perception">Crosstalk’s Effect on Viewer Experience and Perception</h4>
<p>The presence of crosstalk can lead to several problems such as general annoyance, visual discomfort, hindrance of fusing the images together, and breakdown of stereoscopic depth (Daly 2011, Woods 2004). Crosstalk is even considered to be one of the most annoying distortions in the visualization stage of stereoscopic imaging systems (Xing 2012, Seuntiëns 2005). At best, crosstalk is perceivable as a faint halo surrounding the edges of objects (Daly 2011) and at worst it contains all of the previously mentioned problems.</p>
<p>The amount of crosstalk is dependant on disparity and amplitude, with high levels resulting in ghosting (Daly 2011). According to Daly et al with small disparities and low amplitudes, such as in textures, crosstalk is perceivable only as a blur. With moderate amplitudes and disparities, crosstalk appears as tolerable double edges, while higher amplitudes can lead it to be displayed as an annoying ghost image. With even higher levels the double image disturbs stereoscopic fusion and prevents depth effect. Daly et al also point out other viewer experience such as general annoyance and discomfort, which can exist along with ghosting.</p>
<p>Another description of the effect on viewer experience can be found from a paper by Xing et al (Xing 2011). They also claim that comparatively few research efforts have been dedicated to this subject. Their paper does indeed describe an extensive study on the subjective experience of crosstalk. The described study investigated how much crosstalk can be perceived when it is visible. Additionally, this investigation focused on more realistic scenarios where natural scenes varying in crosstalk levels affect the perceptual attributes of crosstalk. This study led to finding three perceptual attributes of crosstalk, which can be categorized to 2D perceptual attributes that exist in a single eye view, and 3D perceptual attributes that exist once the view has been fused. Two of the attributes <em>shadow degree of crosstalk</em> and <em>separation distance of crosstalk</em> belong to 2D perceptual attributes while 3D attributes consist of <em>spatial position</em>.</p>
<p>Xing et al define Shadows degree of crosstalk as the distinctness of crosstalk when compared to the original view. The way it affects crosstalk is that when the level of shadow degree of crosstalk rises, the amount of crosstalk is experienced as more annoying. Xing et al. point out that the contrast of the scene content relates to the shadow degree of crosstalk. Another 2D perceptual attribute that they define is the separation distance of crosstalk, meaning the distance of crosstalk from the original view. According to the paper, crosstalk is experienced as being more annoying with increased separation distance. In addition to the 2D perceptual attributes, which also interact mutually, Xing et al list also one 3D perceptual attribute, spatial position. The spatial position is defined as the impact of crosstalk position in the 3D space on perception when the left and right views are fused and 3D perception is generated (Xing 2011). It is also heavily related to the two earlier mentioned 2D perceptual attributes, having an impact only on the visible crosstalk satisfying requirements of shadow degree and separation distance.</p>
<p>Crosstalk also affects the perceived depth in thin structures and in natural scenes (Tsirlin 2010, Tsirlin 2012). In a paper by Tsirlin et al published in 2010, they demonstrate that as the level of crosstalk increases the magnitude of perceived depth decreases. They also define a maximum level of crosstalk at 4%, at which the perceived depth and visual comfort are at suitable levels. These findings are backed supported by their 2012 paper, in which they assign the 4% level to synthetic images and a lower 2% level for natural images, resulting from crosstalk having a more disruptive effect on natural scenes.</p>
<p>As a conclusion to the subject of crosstalk’s effect on viewer experience, it can be argued that the effects are multiple and that there is also a connection between many of them, as demonstrated by Xing et al (Xing et al 2011). What comes to the level of crosstalk in the 3D system, 4 percentage seems to be the optimal according to Tsirlin et al, but there are also different views as Woods et al make a note of (Woods 2010).</p>
<p>Studying and Measuring Crosstalk</p>
<p>In order or to measure and conduct studies on crosstalk, a crosstalk-free technology has to be used. Earlier mentioned Wheatstone and Helmholtz approach is capable of reaching zero percent level of crosstalk and is therefore often used in crosstalk studies. Often the crosstalk effect is created digitally in this case, which allows the amount of crosstalk to be adjusted by the experimenter quite easily.</p>
<p>Crosstalk can be measured with optical sensors and with the use of visual measurement charts (Woods 2010). In the case of optical sensors, consideration has to be given to how well the sensor’s spectral sensitivity matches that of an eye. Traditionally the measurements are made by measuring the leakage between opposing channels when full-white is projected to the other one and full-black to the other one. This particular metric can be called black-and-white crosstalk and is often used, as it works with the crosstalk formulas described earlier. A variation of this metric is called grey-to-grey crosstalk, which is directed towards crosstalk measurement in time-sequential 3D systems, as crosstalk occurs differently in them. The basic method of this metric consists of measuring multiple grey level combinations and analyzing them.</p>
<p>While the optical sensor method is not necessarily a slow measuring method, a quicker way is to do the measuring with visual measurement charts. However, as this method is based on subjective evaluation there is a greater chance of error than when using sensors (Woods 2010). In addition to this, this measurement has also other limitations, for example, a requirement for calibration with correct gamma sensor, potential differentiation in crosstalk levels in the different parts of the screen, and chart not accounting the non-zero black level of some monitors.</p>
<h4 id="counteracting-crosstalk">Counteracting Crosstalk</h4>
<p>The complete elimination of crosstalk is difficult, and methods that are close to succeeding in this are rarely compact (see Wheatstone &amp; Helmholtz method) (Daly 2011). The methods for counteracting crosstalk are many, starting from enhancing the quality and build of 3D viewing hardware and ending in software-based compensation algorithms. This is a consequence of crosstalk being a sum of many factors. However, it must also be taken into consideration every cost and benefit trade-offs have to be done in order to reduce crosstalk with this method (Woods 2010).</p>
<p>Various signal-processing techniques are being developed, and have proven particularly valuable in hardware approaches using synchronized, alternating viewpoints (Daly 2011). Methods using liquid crystal displays, for example, can be improved with overdrive algorithms. Another algorithm method is known as L-R matrix compensation, which aims to anticipate and remove the ghost images from the images sent to the screen. This way the unwanted crosstalk signal, which is a result of the display process, is pre-subtracted from the digital image. Former method does not, however, work well when crosstalk is caused by high enough contrast. This will cause the subtraction to go negative, which will be impossible for the physical display to achieve, resulting in incomplete crosstalk correction.</p>
<p>Additionally to the algorithmic approach, one could also decrease the level of crosstalk by reducing the contrast ratio of the image, or even the display. Reducing the brightness of the display used for showing the 3D images would also be beneficial (Woods 2010). However would also result in reduced image quality, and for example, in the case of polarized glasses, the end quality could be questionable. This highlights the problem of multiple different technologies – a universal solution to counteracting crosstalk possibly impossible.</p>
<h4 id="conclusion">Conclusion</h4>
<p>This essay has reviewed some of the scientific literature found on the crosstalk effect. The main foci of the paper have been on defining the concept of crosstalk, its relation to different stereoscopic technologies, its relation to the viewer experience, and the ways of counteracting it. In addition to the former, the essay has also briefly highlighted the technological aspects related to crosstalk. This paper cannot be described as a thorough inspection of the crosstalk phenomena but mainly an introduction to it. In order to achieve a more thorough look at the crosstalk phenomena, the essay should be narrowed down t a single stereoscopic imaging technology. This can be also said to be one of the essay’s shortcomings, as there exists a slight change of some of the technologies related to crosstalk getting confused due to the relatively light introduction to stereoscopic technologies.</p>
<p>This essay’s structure has been more akin to a scientific paper than one of an essay. Therefore there has been a relatively little reflection on the subject, and the focus has been mainly on presenting the knowledge found in the literature. However, I personally feel that traditional reflective essay, would not have worked in this case, as many of the subject’s properties are more a matter of fact that a subject of discussion. Another remark I want to make is that the amount of background material is quite small, and there was a noticeable problem of finding related literature that fit the scope of the essay. This can be due to having quite limited knowledge about the context, as the subject itself is relatively well researched.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[What are Touchpoints in service design?]]></title>
            <link>https://perttu.dev/articles/defining-touchpoints-in-service-design</link>
            <guid isPermaLink="false">https://perttu.dev/articles/defining-touchpoints-in-service-design</guid>
            <pubDate>Sat, 17 Oct 2015 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Services are, by nature, intangible. That intangibility is often what separates them from physical products. Still, every service needs some form of tangible expression—artifacts, interactions, environments—that make the experience feel real. Touchpoints are one of the main ways a service becomes tangible.</p>
<p>Exactly what counts as a touchpoint depends on the literature you read. Definitions vary across service design, marketing, branding, and CRM. This article reviews those definitions, highlights where they overlap, and explores how touchpoints are used in service design practice today.</p>
<h2 id="introduction">Introduction</h2>
<p>A common definition is that touchpoints are the moments of contact between a customer and a service provider—specific places and times where a customer’s need is addressed <sup><a href="#user-content-fn-risdon" id="user-content-fnref-risdon" data-footnote-ref="true" aria-describedby="footnote-label">1</a></sup>. Another interpretation is that touchpoints are the elements that make a service experienceable and help build the service’s brand <sup><a href="#user-content-fn-hogan" id="user-content-fnref-hogan" data-footnote-ref="true" aria-describedby="footnote-label">2</a></sup>.</p>
<p>Both definitions highlight very different aspects of touchpoints, and this variety reflects a broader issue: the term has no universally accepted meaning. This creates problems for both research and practice. When designers reference “touchpoints” without a clear shared definition, misunderstandings are easy, and design approaches may become too narrow or inconsistent.</p>
<p>Touchpoints are considered one of the central aspects of service design because they highlight key differences between products and services <sup><a href="#user-content-fn-clatworthy" id="user-content-fnref-clatworthy" data-footnote-ref="true" aria-describedby="footnote-label">3</a></sup> <sup><a href="#user-content-fn-risdon" id="user-content-fnref-risdon-2" data-footnote-ref="true" aria-describedby="footnote-label">1</a></sup> <sup><a href="#user-content-fn-secomandi" id="user-content-fnref-secomandi" data-footnote-ref="true" aria-describedby="footnote-label">4</a></sup>. Yet despite their centrality, the concept remains abstract. This article examines how touchpoints are defined in different literatures, then focuses on how service design tools use the concept in practice.</p>
<h2 id="origin-of-the-word">Origin of the Word</h2>
<p>The term touchpoint appears in several disciplines, and its exact origin in service design is unclear. Early uses can be found in branding and trade publications in the early 1990s, where it referred to moments of contact between a customer and a company (Howard 2007). These early definitions resemble the ones used in service design today, though they weren’t identical.</p>
<p>Before “touchpoint” entered service design, similar concepts already existed:</p>
<ul>
<li>Service evidence — the physical elements of a service <sup><a href="#user-content-fn-shostack" id="user-content-fnref-shostack" data-footnote-ref="true" aria-describedby="footnote-label">5</a></sup></li>
<li>Service encounter — the interactions between customer and provider <sup><a href="#user-content-fn-bitner" id="user-content-fnref-bitner" data-footnote-ref="true" aria-describedby="footnote-label">6</a></sup></li>
</ul>
<p>Other related terms include moment of truth, contact point, point of contact, and customer contact <sup><a href="#user-content-fn-clatworthy" id="user-content-fnref-clatworthy-2" data-footnote-ref="true" aria-describedby="footnote-label">3</a></sup> <sup><a href="#user-content-fn-s%C3%B6derlund" id="user-content-fnref-s%C3%B6derlund" data-footnote-ref="true" aria-describedby="footnote-label">7</a></sup>.</p>
<h3 id="touchpoints-vs-channels">Touchpoints vs. channels</h3>
<p>In CRM literature, “touchpoints” and “touch-points” appear frequently, but the field often talks more about multi-channel delivery <sup><a href="#user-content-fn-clatworthy" id="user-content-fnref-clatworthy-3" data-footnote-ref="true" aria-describedby="footnote-label">3</a></sup> <sup><a href="#user-content-fn-howard" id="user-content-fnref-howard" data-footnote-ref="true" aria-describedby="footnote-label">8</a></sup>. The important distinction is:</p>
<ul>
<li>A channel is broad (e.g., “Twitter”, “in-store”, “email”).</li>
<li>A touchpoint is the specific expression of that channel (e.g., a company’s Twitter account, a store kiosk).</li>
</ul>
<p>Chris Risdon explains this clearly:</p>
<blockquote>
<p>“A single touchpoint — a customer getting their rental car — but a concert of channels: physical retail space, video with remote agent, touchscreen kiosk interface.” <sup><a href="#user-content-fn-risdon" id="user-content-fnref-risdon-3" data-footnote-ref="true" aria-describedby="footnote-label">1</a></sup></p>
</blockquote>
<h3 id="other-uses">Other uses</h3>
<p>Clatworthy notes that the term “emotional touchpoint” is also used in medical research, where it serves as a tool for gathering patient experience data <sup><a href="#user-content-fn-clatworthy" id="user-content-fnref-clatworthy-4" data-footnote-ref="true" aria-describedby="footnote-label">3</a></sup>. Although the domain differs, the idea—highlighting emotionally significant moments in a journey—aligns closely with service design.</p>
<p>Across all these fields, the term is used in slightly different ways, which helps explain why no unified definition exists.</p>
<h2 id="touchpoints-in-service-design-literature">Touchpoints in Service Design Literature</h2>
<p>Instead of trying to create a cross-disciplinary definition, a more useful approach is to focus on how service design research itself treats touchpoints. This helps reveal the practical and conceptual challenges caused by inconsistent definitions.</p>
<h3 id="at-one-and-cross-disciplinary-process">AT-ONE and Cross-disciplinary Process</h3>
<p>The AT-ONE project is a service design research initiative aimed at supporting cross-functional teams in the early stages of service development <sup><a href="#user-content-fn-clatworthy" id="user-content-fnref-clatworthy-5" data-footnote-ref="true" aria-describedby="footnote-label">3</a></sup>. Touchpoints are so central to the project that the “T” in AT-ONE stands for them.</p>
<p>The project includes touchpoint workshops and a card-based tool with three main functions:</p>
<h4 id="a-team-building">A. Team building</h4>
<ol>
<li>Build shared understanding of touchpoints</li>
<li>Strengthen cross-disciplinary collaboration</li>
</ol>
<h4 id="b-analysis-and-mapping">B. Analysis and mapping</h4>
<ol>
<li>Identify all touchpoints in a customer journey</li>
<li>Highlight critical touchpoints</li>
<li>Understand each touchpoint’s constraints and possibilities</li>
<li>Identify ownership and responsibilities</li>
</ol>
<h4 id="c-idea-generation">C. Idea generation</h4>
<ol>
<li>Explore new touchpoint ideas or redesign existing ones</li>
</ol>
<p>A challenge noted in the research is that touchpoints are often referenced through examples rather than definitions. For instance, an iPad is listed as a touchpoint in the card set. But an iPad alone does not constitute a touchpoint unless it’s tied to a service interaction. This ambiguity illustrates how difficult it is to define touchpoints without context.</p>
<h3 id="sequencing-and-mapping-through-touchpoints">Sequencing and Mapping Through Touchpoints</h3>
<p>Stickdorn and Schneider’s This Is Service Design Thinking distinguishes between touchpoints and interactions, which together form “service moments” <sup><a href="#user-content-fn-stickdorn" id="user-content-fnref-stickdorn" data-footnote-ref="true" aria-describedby="footnote-label">9</a></sup>. Touchpoints can occur between:</p>
<ul>
<li>human and human</li>
<li>human and machine</li>
<li>machine and machine</li>
</ul>
<p>The last category is not intuitive from a customer perspective, but machine-to-machine interaction can shape the service experience. For example, a health app may send data to a medical provider behind the scenes. The customer might not realize this is a touchpoint, but it still influences the service.</p>
<p>Stickdorn argues that touchpoints are essential for:</p>
<p><strong>Sequencing</strong></p>
<p>Touchpoints structure the phases of a customer journey. They provide the backbone for tools such as service blueprints, journey maps, and service experience blueprints (SEBs) <sup><a href="#user-content-fn-bitner2" id="user-content-fnref-bitner2" data-footnote-ref="true" aria-describedby="footnote-label">10</a></sup> <sup><a href="#user-content-fn-patr%C3%ADcio" id="user-content-fnref-patr%C3%ADcio" data-footnote-ref="true" aria-describedby="footnote-label">11</a></sup>.</p>
<p><strong>Evidencing</strong></p>
<p>Touchpoints make invisible service processes tangible. This helps customers understand what’s happening behind the scenes and strengthens the perceived value of the service.</p>
<p>These ideas align with the work of Bitner, Clatworthy, Patricio, Risdon, Shostack, and others, who all highlight the role of touchpoints in making services concrete and comprehensible.</p>
<h2 id="conclusion">Conclusion</h2>
<p>There is no single, definitive definition of “touchpoint” in service design. Given the field’s multidisciplinary origins, this isn’t surprising. As service design matures, the terminology will likely settle into more widely shared meanings.</p>
<p>Even without a universal definition, certain characteristics repeatedly appear across the literature:</p>
<ul>
<li><strong>Materiality and tangibility</strong> — Touchpoints often involve physical or digital artifacts that make the service real.</li>
<li><strong>Interactivity</strong> — Touchpoints involve actions or exchanges.</li>
<li><strong>Context-dependence</strong> — Their meaning depends on the situation and channel.</li>
<li><strong>Sequencing</strong> — They structure the customer journey.</li>
<li><strong>Evidencing</strong> — They reveal otherwise invisible aspects of the service.</li>
</ul>
<p>Touchpoints may never have a perfectly clean definition, and perhaps they don’t need one. What matters is understanding how they function in service experiences and using them deliberately to create coherent, meaningful journeys.</p>
<h2 id="references">References</h2>
<section data-footnotes="true" class="footnotes"><h2 class="sr-only" id="footnote-label">Footnotes</h2>
<ol>
<li id="user-content-fn-risdon">
<p>Risdon, C. (2013). Unsucking the touchpoint. Adaptive Path. <a href="#user-content-fnref-risdon" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-risdon-2" data-footnote-backref="" aria-label="Back to reference 1-2" class="data-footnote-backref">↩<sup>2</sup></a> <a href="#user-content-fnref-risdon-3" data-footnote-backref="" aria-label="Back to reference 1-3" class="data-footnote-backref">↩<sup>3</sup></a></p>
</li>
<li id="user-content-fn-hogan">
<p>Hogan, S., Almquist, E., &amp; Glynn, S. E. (2005). Brand-building: finding the touchpoints that count. Journal of Business Strategy, 26(2), 11–18. <a href="#user-content-fnref-hogan" data-footnote-backref="" aria-label="Back to reference 2" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-clatworthy">
<p>Clatworthy, S. (2011). Service innovation through touch-points: Development of an innovation toolkit for the first stages of new service development. <a href="#user-content-fnref-clatworthy" data-footnote-backref="" aria-label="Back to reference 3" class="data-footnote-backref">↩</a> <a href="#user-content-fnref-clatworthy-2" data-footnote-backref="" aria-label="Back to reference 3-2" class="data-footnote-backref">↩<sup>2</sup></a> <a href="#user-content-fnref-clatworthy-3" data-footnote-backref="" aria-label="Back to reference 3-3" class="data-footnote-backref">↩<sup>3</sup></a> <a href="#user-content-fnref-clatworthy-4" data-footnote-backref="" aria-label="Back to reference 3-4" class="data-footnote-backref">↩<sup>4</sup></a> <a href="#user-content-fnref-clatworthy-5" data-footnote-backref="" aria-label="Back to reference 3-5" class="data-footnote-backref">↩<sup>5</sup></a></p>
</li>
<li id="user-content-fn-secomandi">
<p>Secomandi, F., &amp; Snelders, D. (2011). The object of service design. Design Issues, 27(3), 20–34. <a href="#user-content-fnref-secomandi" data-footnote-backref="" aria-label="Back to reference 4" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-shostack">
<p>Shostack, G. L. (1977). Breaking free from product marketing. The Journal of Marketing, 73–80. <a href="#user-content-fnref-shostack" data-footnote-backref="" aria-label="Back to reference 5" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-bitner">
<p>Bitner, M. J., Booms, B. H., &amp; Tetreault, M. S. (1990). The service encounter: diagnosing favorable and unfavorable incidents. The Journal of Marketing, 71–84. <a href="#user-content-fnref-bitner" data-footnote-backref="" aria-label="Back to reference 6" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-s%C3%B6derlund">
<p>Söderlund, M., &amp; Julander, C. R. (2009). Physical attractiveness of the service worker in the moment of truth and its effects on customer satisfaction. Journal of Retailing and Consumer Services, 16(3), 216–226. <a href="#user-content-fnref-s%C3%B6derlund" data-footnote-backref="" aria-label="Back to reference 7" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-howard">
<p>Howard, J. (2007). On the origin of touchpoints. <a href="http://designforservice.wordpress.com/2007/11/07/on-the-origin-of-touchpoints/">http://designforservice.wordpress.com/2007/11/07/on-the-origin-of-touchpoints/</a> <a href="#user-content-fnref-howard" data-footnote-backref="" aria-label="Back to reference 8" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-stickdorn">
<p>Stickdorn, M., &amp; Schneider, J. (2011). This is Service Design Thinking. Wiley. <a href="#user-content-fnref-stickdorn" data-footnote-backref="" aria-label="Back to reference 9" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-bitner2">
<p>Bitner, M. J., Ostrom, A. L., &amp; Morgan, F. N. (2008). Service blueprinting: A practical technique for service innovation. California Management Review, 50(3), 66. <a href="#user-content-fnref-bitner2" data-footnote-backref="" aria-label="Back to reference 10" class="data-footnote-backref">↩</a></p>
</li>
<li id="user-content-fn-patr%C3%ADcio">
<p>Patrício, L., Fisk, R. P., &amp; Constantine, L. (2011). Multilevel service design: from customer value constellation to service experience blueprinting. Journal of Service Research. <a href="#user-content-fnref-patr%C3%ADcio" data-footnote-backref="" aria-label="Back to reference 11" class="data-footnote-backref">↩</a></p>
</li>
</ol>
</section>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Designing a winning hackathon concept]]></title>
            <link>https://perttu.dev/articles/designing-a-winning-hackathon-concept</link>
            <guid isPermaLink="false">https://perttu.dev/articles/designing-a-winning-hackathon-concept</guid>
            <pubDate>Fri, 11 Jan 2019 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Between the years 2015 and 2018 I participated in over 70 hackathons. I won 40 of them, most wins being towards the end. Sometimes winning was mostly accidental, but majority of the time it was due to a strategy me and our team followed. This article is abot trying to write down the strategy that worked.</p>
<p>Hackathons have changed over the past years, and they are no longer pure 24h+ coding sprints. Of course, coding still plays a large role in hackathons, and nicely coded things are still highly regarded. But nowadays, the service concept behind the hackathon project, the design of the service, and the business model also play key roles. Service-designed solutions in hackathons are now more common than ever. <strong>But what is service design, and how do you design a service in a hackathon?</strong></p>
<p>I decided to put together a small guide on how to do successful service design in a hackathon and create a good concept (service idea). My method focuses heavily on the problem-solving aspect of it all because all good services and products always solve a customer’s problem. The following process is based on how I've done things in hackathons, and it has proven to be very successful in a variety of hackathons.</p>
<h3 id="choose-whose-problems-you-are-solving">Choose Whose Problems You Are Solving</h3>
<blockquote>
<p>“We want to make things easier for elderly people.”</p>
</blockquote>
<p>Before rushing into the problem, it is best to choose whose problems your service is going to solve—the targeted customer group of the service. This helps in narrowing down the problem and makes it easier to develop the service concept for the problem. It’s best to settle on a target group even if you have already come up with a problem you are going to solve.</p>
<p>There are multiple ways of finding a suitable target group, and they could easily warrant a separate article. One easy way to pick a target group is to pick one you yourself are part of, e.g., student, elderly person, etc., and develop that into a persona. Using yourself as a model of a potential customer is heavily biased and can lead to false assumptions, for example, about the customer’s capabilities. In the context of hackathons, this is, however, not that critical. If you want to avoid this and do things in a bit harder way, you can focus on a group of people you are not familiar with and do research on them. Pick, for example, city planners and interview them in order to understand their problems (this is what we did in one hackathon).</p>
<h3 id="find-a-problem-to-solve">Find a Problem to Solve</h3>
<blockquote>
<p>“We plan to make commuting easier for elderly people.”</p>
</blockquote>
<p>After you have settled on the target group, it’s time to find out their biggest problem by either observing their behavior in context or interviewing them. If you’re using yourself, think about the problems you’re facing daily. What problems do you face daily? Is there perhaps something that takes a considerably large amount of your time, money, or other resources? In what context does this usually happen? The context in this case could be, for example, commuting.</p>
<p>The best option is to pick one concrete and recognizable problem that is solvable within the hackathon’s time frame. If your target group is, for example, the aforementioned elderly people, and their context is commuting, the problem you’re trying to solve could be, for example, how commuting could be made easier for elderly people. When formatted as a problem statement: “How to make commuting easier for elderly people.”</p>
<h3 id="how-does-your-service-solve-the-problem">How Does Your Service Solve the Problem?</h3>
<blockquote>
<p>“We plan to make a crowdsourced helpline for elderly people that makes their commuting easier.”</p>
</blockquote>
<p>After finding and understanding the problem, it’s time to come up with a solution to it. Now, I can’t give any specific tools for coming up with solutions, but I’ve personally found that brainstorming is a good method to start with. The most important thing in coming up with the solution is to make it simple and doable in a hackathon. The solution should also be quick and easy to comprehend. In hackathons, you have to pitch your idea, usually in under three minutes. During this time, you have to describe the problem, who it affects, how you have solved the problem, and how your solution works. Therefore, a simple service solution is often better than a complicated one in a hackathon.</p>
<p>You should try to fit your service solution into one sentence, which should describe the problem, your target group, and your solution to it. To give an example, we can use the elderly people again. One way to solve their problems in commuting could be, for example, a crowdsourced helpline where the elderly can call and receive instructions on commuting. When put in one sentence: “We plan to make a crowdsourced mobile helpline for elderly people that makes their commuting easier.”</p>
<h3 id="how-does-your-solution-work">How Does Your Solution Work?</h3>
<blockquote>
<p>Customers, customers’ problem, solution to the problem <em>→ the service built around these that works like this.</em></p>
</blockquote>
<p>Service is always a combination of multiple different pieces, where the problem and the solution are forces tying everything together. In hackathons, you rarely have time to think about the whole service structure, but you do have enough time to make a light <strong><a href="https://www.smashingmagazine.com/2015/01/all-about-customer-journey-mapping/">customer journey</a>.</strong> The customer journey, in this case, means a description of how the customer finds the service, how the service solves the problem the customer is facing, what is the role of the service after this, and how the service is different from others, for example.</p>
<p>Creating a customer journey is a rather broad task that brings out the benefits of a multidisciplinary team. Creating a simple but easily understandable customer journey is a lot easier with a versatile team.</p>
<p>In addition, it might also be beneficial to come up with a business plan for the hackathon concept, as that is also usually used as a way to evaluate the team’s idea.</p>
<h3 id="conclusion">Conclusion</h3>
<p>With these instructions, it is possible to create a winning hackathon service concept. It should, however, be noted that a concept by itself is rarely enough and should be accompanied by a demo that demonstrates key elements of the service. Creating the service concept and a technical demo of it has to be done in parallel, which makes hackathon concepts with both a good service concept and a cool demo a rare sight. These are, however, often the solutions that end up winning.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Detect If Another iOS App Is Installed Using SwiftUI]]></title>
            <link>https://perttu.dev/articles/detect-installed-ios-app-swiftui</link>
            <guid isPermaLink="false">https://perttu.dev/articles/detect-installed-ios-app-swiftui</guid>
            <pubDate>Sat, 13 Dec 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p>If you want to check whether another iOS app is installed from your SwiftUI app, use <code>UIApplication.shared.canOpenURL()</code> with the target app's URL scheme. You must declare the schemes you want to query in your <code>Info.plist</code> under <code>LSApplicationQueriesSchemes</code>. This article shows you exactly how to set it up.</p>
<hr>
<p>I'm building <strong>a companion app for Flighty</strong> in SwiftUI, and I wanted to detect whether Flighty app was already installed on the user’s device, in order to ask them to open it and export their flight data. I have written instructions how to do this in the app's onboarding, but providing a direct link to the app provides a way smoother UX. Since Flighty itself isn’t my app, I need to</p>
<p>If Flighty is installed, I can show a shortcut that opens it directly. If it isn’t, that option stays hidden.</p>
<h2 id="the-basic-idea">The basic idea</h2>
<p>iOS allows apps to check whether another app is installed by asking the system if it can open a specific URL scheme.</p>
<p>There are some important constraints:</p>
<ul>
<li>You must declare the URL schemes you want to query in <code>Info.plist</code></li>
<li>Apple expects you to have a legitimate reason for checking</li>
<li>Querying lots of unrelated apps can lead to App Review questions</li>
</ul>
<p>Used carefully, this is a supported and documented approach.</p>
<h2 id="step-1-identify-the-apps-url-scheme">Step 1: Identify the app’s URL scheme</h2>
<p>Many apps expose a custom URL scheme, either publicly documented or discoverable through testing. Flighty exposes the <code>flighty://</code> scheme, which makes this possible. If you write that in your browser and press enter, it should prompt to open Flighty. On iOS you can then call the scheme with <code>canOpenURL</code> to see if the app is installed – as long as you've registered the scheme in your app first.</p>
<h2 id="step-2-register-the-scheme-in-infoplist">Step 2: Register the scheme in Info.plist</h2>
<p>You must explicitly add this scheme to your apps' Info.plist file, using <code>Queried URL Schemes</code> (key is <code>LSApplicationQueriesSchemes</code>). You can do this either through Xcode or by editing the <code>Info.plist</code> directly.</p>
<p>In Xcode navigate to Targets → You app name → Info tab → Custom iOS Target Properties:</p>
<img alt="Xcode Info.plist" loading="lazy" width="2012" height="836" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fxcode-info-plist.1eaf484e.png&amp;w=2048&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fxcode-info-plist.1eaf484e.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fxcode-info-plist.1eaf484e.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>Alternatively, editing <code>Info.plist</code> directly, add this:</p>
<pre class="language-xml"><code class="language-xml"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>key</span><span class="token punctuation">&gt;</span></span>LSApplicationQueriesSchemes<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>key</span><span class="token punctuation">&gt;</span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>array</span><span class="token punctuation">&gt;</span></span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>string</span><span class="token punctuation">&gt;</span></span>flighty<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>string</span><span class="token punctuation">&gt;</span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>array</span><span class="token punctuation">&gt;</span></span>
</code></pre>
<p>This flags tells iOS that your app is allowed to ask whether <code>flighty://</code> can be opened. Without this, <code>canOpenURL</code> will always return false.</p>
<h2 id="step-3-check-if-the-app-is-installed">Step 3: Check if the app is installed</h2>
<p>With the scheme registered, the runtime check is simple. Here’s the property I use in my SwiftUI app:</p>
<pre class="language-swift"><code class="language-swift"><span class="token keyword">private</span> <span class="token keyword">var</span> isFlightyInstalled<span class="token punctuation">:</span> <span class="token class-name">Bool</span> <span class="token punctuation">{</span>
    <span class="token keyword">guard</span> <span class="token keyword">let</span> url <span class="token operator">=</span> <span class="token function">URL</span><span class="token punctuation">(</span>string<span class="token punctuation">:</span> <span class="token string-literal"><span class="token string">"flighty://"</span></span><span class="token punctuation">)</span> <span class="token keyword">else</span> <span class="token punctuation">{</span> <span class="token keyword">return</span> <span class="token boolean">false</span> <span class="token punctuation">}</span>
    <span class="token keyword">return</span> <span class="token class-name">UIApplication</span><span class="token punctuation">.</span>shared<span class="token punctuation">.</span><span class="token function">canOpenURL</span><span class="token punctuation">(</span>url<span class="token punctuation">)</span>
<span class="token punctuation">}</span>
</code></pre>
<p>If this returns <code>true</code>, the app is installed on the device. I use this in my app to conditionally render UI, showing a button that opens Flighty only when it’s actually available.</p>
<img alt="Example of a button that opens Flighty only when it is installed" loading="lazy" width="3840" height="2160" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fapp.7af9bd19.jpeg&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fapp.7af9bd19.jpeg&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<h3 id="opening-the-other-app">Opening the other app</h3>
<p>Once you know the app is installed, opening it is straightforward:</p>
<pre class="language-swift"><code class="language-swift"><span class="token keyword">private</span> <span class="token keyword">func</span> <span class="token function-definition function">openFlighty</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword">if</span> <span class="token keyword">let</span> url <span class="token operator">=</span> <span class="token function">URL</span><span class="token punctuation">(</span>string<span class="token punctuation">:</span> <span class="token string-literal"><span class="token string">"flighty://"</span></span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token class-name">UIApplication</span><span class="token punctuation">.</span>shared<span class="token punctuation">.</span><span class="token keyword">open</span><span class="token punctuation">(</span>url<span class="token punctuation">)</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<h3 id="a-note-on-privacy-and-app-review">A note on privacy and App Review</h3>
<p>You should be careful when checking for other apps, there used to be no limitations on this but some ad SDK apparently abused this method. But if you're doing it for a legitimate purpose, Apple is generally fine with this pattern when:</p>
<ul>
<li>The integration is clear and user-facing</li>
<li>You only query the specific app you integrate with</li>
<li>You’re transparent about what you’re doing</li>
</ul>
<p>Be explicit in your App Review notes and privacy policy about why you check for the other app. The goal here is user convenience, not tracking.</p>
<h2 id="final-thoughts">Final thoughts</h2>
<p>This is a small feature, but it can make a big difference in the user experience. I like these kinds of UX "hacks", that you most likely won't even pay that much attention to, but which just make the life of the user a tiny bit easier when interacting with your app.</p>
<hr>
<h2 id="related-articles">Related articles</h2>
<ul>
<li><a href="/articles/in-app-purchases-rn">React Native in-app purchases guide</a> - Monetize your iOS and Android apps</li>
<li><a href="/articles/checklist-for-releasing-apps">App release checklist</a> - Don't forget these before shipping</li>
</ul>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[React Query + FlatList: Fix Flickering and Layout Shifts]]></title>
            <link>https://perttu.dev/articles/flatlists-with-react-query</link>
            <guid isPermaLink="false">https://perttu.dev/articles/flatlists-with-react-query</guid>
            <pubDate>Fri, 21 Nov 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p>If you're seeing flickering, layout shifts, or scroll position resets when using React Query with FlatList in React Native, it's usually caused by conditionally replacing the entire screen based on loading state. The fix is to always render the FlatList and use <code>ListEmptyComponent</code> for loading and empty states.</p>
<p>This article explains why it happens and how to fix it properly.</p>
<hr>
<p>React query pairs incredibly well with <code>FlatList</code> (and <code>FlashList</code>, and <code>LegendList</code>, if you prefer to use those). You get fast, predictable data fetching combined with efficient virtualized rendering. The challenge is keeping the UI stable while handling loading, empty states, refetches, skeletons, and safe area adjustments. Unfortunately LLMs seems to be always giving worst possible options for handling all of these.</p>
<h2 id="avoid-conditionally-replacing-the-entire-screen">Avoid conditionally replacing the entire screen</h2>
<p>A common bad patterns is to conditionally replace the entire screen based on the query state. For example, rendering a loading spinner during the initial fetch, then rendering an empty state if no data exists, and only rendering the list once real items arrive. This creates three separate component trees for the same screen and forces React to constantly mount and unmount the scroll container. The result is flickering, layout shifts, and scroll position resets.</p>
<p>A better approach is to always render the list component and use <code>ListEmptyComponent</code> to show whatever should appear when the list has no rows. This keeps the list mounted from the moment the screen appears and makes transitions between states much smoother.</p>
<p>Here's an example of what to avoid:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword control-flow">if</span> <span class="token punctuation">(</span>isLoading<span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">LoadingSpinner</span></span> <span class="token punctuation">/&gt;</span></span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token keyword control-flow">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>data<span class="token operator">?.</span>length<span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">EmptyState</span></span> <span class="token punctuation">/&gt;</span></span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">FlatList</span></span>
    <span class="token attr-name">data</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>data<span class="token punctuation">}</span></span>
    <span class="token attr-name">renderItem</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>renderItem<span class="token punctuation">}</span></span>
  <span class="token punctuation">/&gt;</span></span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>And here's the correct approach:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> <span class="token punctuation">{</span> data<span class="token punctuation">,</span> isLoading<span class="token punctuation">,</span> isError <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useQuery</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  queryKey<span class="token operator">:</span> <span class="token punctuation">[</span><span class="token string">"items"</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
  queryFn<span class="token operator">:</span> fetchItems<span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

<span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">FlatList</span></span>
    <span class="token attr-name">data</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>data <span class="token operator">??</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">}</span></span>
    <span class="token attr-name">renderItem</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>renderItem<span class="token punctuation">}</span></span>
    <span class="token attr-name">ListEmptyComponent</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>
      <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">EmptyState</span></span>
        <span class="token attr-name">isLoading</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>isLoading<span class="token punctuation">}</span></span>
        <span class="token attr-name">isError</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>isError<span class="token punctuation">}</span></span>
      <span class="token punctuation">/&gt;</span></span>
    <span class="token punctuation">}</span></span>
  <span class="token punctuation">/&gt;</span></span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>This is better because if data is undefined, the list will render the <code>EmptyState</code> component, but the actual list element won't be unmounted when data is gone. This pattern works for <code>FlatList</code>, <code>FlashList</code>, and <code>LegendList</code> because all three support an equivalent empty state component. You can use the  the same <code>EmptyState</code> component to display both loading data, error states, and the actual empty state. This component is also a prime place to put a retry button.</p>
<h2 id="list-should-be-the-single-scroll-container-for-the-screen">List should be the single scroll container for the screen</h2>
<p>Another important rule is that the list should be the single scroll container for the screen. That means header content, filters, and summary sections should live inside <code>ListHeaderComponent</code>, not above the list in a parent view. When they're above they don't scroll so don't do it (unless that's the behavior you want). Don't nest a <code>FlatList</code> inside a <code>ScrollView</code> either.</p>
<p>Example of the broken pattern:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">View</span></span> <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">{</span> flex<span class="token operator">:</span> <span class="token number">1</span> <span class="token punctuation">}</span><span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Header</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Filters</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">FlatList</span></span> <span class="token attr-name">data</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>items<span class="token punctuation">}</span></span> <span class="token attr-name">renderItem</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>renderItem<span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
  </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">View</span></span><span class="token punctuation">&gt;</span></span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>Instead make use of the <code>ListHeaderComponent</code> to render everything you want to show above the scroll container, but that still needs to scroll. Similarly use the <code>ListFooterComponent</code> for everything that should go under the list elements. Keep in mind that these elements will be rendered always, even if the data array is empty, so make use that your <code>ListEmptyComponent</code></p>
<p>Correct version:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">ListHeader</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Header</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Filters</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Summary</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
  </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span></span><span class="token punctuation">&gt;</span></span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>

<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">FlashList</span></span>
  <span class="token attr-name">data</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>items<span class="token punctuation">}</span></span>
  <span class="token attr-name">renderItem</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>renderItem<span class="token punctuation">}</span></span>
  <span class="token attr-name">ListHeaderComponent</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">ListHeader</span></span> <span class="token punctuation">/&gt;</span></span><span class="token punctuation">}</span></span>
<span class="token punctuation">/&gt;</span></span><span class="token punctuation">;</span>
</code></pre>
<h2 id="flatlist-should-be-the-top-level-component-of-the-screen">FlatList should be the top level component of the screen</h2>
<p><code>FlatList</code> should also be the top level component of the screen. I've seen a lot of people wrap this list in a parent container such as a <code>View</code> with flex set to one. Same goes for AI assisted coding with LLMS pushing the same element. <code>ScrollViews</code> have props for styling the containers so you don't need any other views around the list component itself.</p>
<p>Problematic example:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">View</span></span> <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">{</span> flex<span class="token operator">:</span> <span class="token number">1</span> <span class="token punctuation">}</span><span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
  </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">FlatList</span></span> <span class="token attr-name">data</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>items<span class="token punctuation">}</span></span> <span class="token attr-name">renderItem</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>renderItem<span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">View</span></span><span class="token punctuation">&gt;</span></span>
</code></pre>
<p>Correct approach:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">FlatList</span></span>
  <span class="token attr-name">data</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>items<span class="token punctuation">}</span></span>
  <span class="token attr-name">renderItem</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>renderItem<span class="token punctuation">}</span></span>
  <span class="token attr-name">contentInsetAdjustmentBehavior</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>automatic<span class="token punctuation">"</span></span>
<span class="token punctuation">/&gt;</span></span>
</code></pre>
<p>By setting <code>contentInsetAdjustmentBehavior</code> to automatic when the list is the root component, the system applies correct safe area spacing at the top and bottom. This ensures consistent behavior across devices and navigation setups without extra layout hacks.</p>
<p>React query has different states such as <code>isLoading</code>, <code>isRefetching</code>, <code>isError</code>, and <code>success</code>. None of these should cause you to remove or replace the list. Instead, integrate them into the list itself. <code>isLoading</code> and empty states should be represented through <code>ListEmptyComponent</code>. <code>isRefetching</code> should trigger a subtle inline indicator, such as a pull to refresh spinner or a small header badge, rather than replacing the visible content.</p>
<p>Skeleton placeholders make the loading experience feel even more immediate. Instead of a blank screen or large spinner, create a small set of placeholder rows and pass them to the list whenever the initial load has not completed. The list will render items instantly and the user will see the structure of the content even if the actual data is not available to use yet.</p>
<p>Skeleton placeholder array:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> <span class="token constant">SKELETON_ITEMS</span> <span class="token operator">=</span> <span class="token known-class-name class-name">Array</span><span class="token punctuation">.</span><span class="token keyword module">from</span><span class="token punctuation">(</span><span class="token punctuation">{</span> length<span class="token operator">:</span> <span class="token number">10</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">map</span><span class="token punctuation">(</span><span class="token punctuation">(</span>_<span class="token punctuation">,</span> index<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span><span class="token punctuation">{</span>
  id<span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">skeleton-</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>index<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
  isSkeleton<span class="token operator">:</span> <span class="token boolean">true</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>Using skeletons during initial load:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> items <span class="token operator">=</span> isLoading <span class="token operator">&amp;&amp;</span> <span class="token operator">!</span>data
  <span class="token operator">?</span> <span class="token constant">SKELETON_ITEMS</span>
  <span class="token operator">:</span> data <span class="token operator">??</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
</code></pre>
<p>Then in <code>renderItem</code>:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> <span class="token function-variable function">renderItem</span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">{</span> item <span class="token punctuation">}</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">if</span> <span class="token punctuation">(</span>item<span class="token punctuation">.</span><span class="token property-access">isSkeleton</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword control-flow">return</span> <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">MySkeletonItem</span></span> <span class="token punctuation">/&gt;</span></span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>

  <span class="token keyword control-flow">return</span> <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">MyItem</span></span> <span class="token attr-name">item</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>item<span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>
</code></pre>
<h2 id="co-locate-component-and-skeleton-component">Co-locate component and skeleton component</h2>
<p>The final key practice is to co-locate skeleton item components with their real counterparts. The skeleton layout should mirror the real layout exactly, including containers, spacing, and alignment. Only the content should differ. If the two components drift apart in your codebase, layout inconsistencies will appear.</p>
<p>Example of paired components that share structure:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">MyItemLayout</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">{</span> left<span class="token punctuation">,</span> title<span class="token punctuation">,</span> subtitle <span class="token punctuation">}</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">View</span></span> <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>styles<span class="token punctuation">.</span><span class="token property-access">container</span><span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
    </span><span class="token punctuation">{</span>left<span class="token punctuation">}</span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">View</span></span> <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>styles<span class="token punctuation">.</span><span class="token property-access">textContainer</span><span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token punctuation">{</span>title<span class="token punctuation">}</span><span class="token plain-text">
      </span><span class="token punctuation">{</span>subtitle<span class="token punctuation">}</span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">View</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
  </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">View</span></span><span class="token punctuation">&gt;</span></span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">MyItem</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">{</span> item <span class="token punctuation">}</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">MyItemLayout</span></span>
    <span class="token attr-name">left</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Avatar</span></span> <span class="token attr-name">uri</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>item<span class="token punctuation">.</span><span class="token property-access">avatarUrl</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token punctuation">}</span></span>
    <span class="token attr-name">title</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Text</span></span> <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>styles<span class="token punctuation">.</span><span class="token property-access">title</span><span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token punctuation">{</span>item<span class="token punctuation">.</span><span class="token property-access">title</span><span class="token punctuation">}</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span><span class="token punctuation">}</span></span>
    <span class="token attr-name">subtitle</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Text</span></span> <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>styles<span class="token punctuation">.</span><span class="token property-access">subtitle</span><span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token punctuation">{</span>item<span class="token punctuation">.</span><span class="token property-access">subtitle</span><span class="token punctuation">}</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span><span class="token punctuation">}</span></span>
  <span class="token punctuation">/&gt;</span></span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">MySkeletonItem</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">MyItemLayout</span></span>
    <span class="token attr-name">left</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">SkeletonCircle</span></span> <span class="token attr-name">size</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token number">40</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token punctuation">}</span></span>
    <span class="token attr-name">title</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">SkeletonLine</span></span> <span class="token attr-name">width</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>60%<span class="token punctuation">"</span></span> <span class="token punctuation">/&gt;</span></span><span class="token punctuation">}</span></span>
    <span class="token attr-name">subtitle</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">SkeletonLine</span></span> <span class="token attr-name">width</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>40%<span class="token punctuation">"</span></span> <span class="token punctuation">/&gt;</span></span><span class="token punctuation">}</span></span>
  <span class="token punctuation">/&gt;</span></span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>I find it easier to reason over skeleton layouts when they are located in the same component as the component they are "skeletoning".</p>
<p>Here's a complete example of everything put together using <code>FlatList</code>, although the same structure works for <code>FlashList</code> and <code>LegendList</code>.</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> <span class="token constant">SKELETON_ITEMS</span> <span class="token operator">=</span> <span class="token known-class-name class-name">Array</span><span class="token punctuation">.</span><span class="token keyword module">from</span><span class="token punctuation">(</span><span class="token punctuation">{</span> length<span class="token operator">:</span> <span class="token number">8</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">map</span><span class="token punctuation">(</span><span class="token punctuation">(</span>_<span class="token punctuation">,</span> index<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span><span class="token punctuation">{</span>
  id<span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">skeleton-</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>index<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
  isSkeleton<span class="token operator">:</span> <span class="token boolean">true</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 keyword module">export</span> <span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">ItemsScreen</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">{</span> data<span class="token punctuation">,</span> isLoading<span class="token punctuation">,</span> isError<span class="token punctuation">,</span> isFetching<span class="token punctuation">,</span> refetch <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useQuery</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
    queryKey<span class="token operator">:</span> <span class="token punctuation">[</span><span class="token string">"items"</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
    queryFn<span class="token operator">:</span> fetchItems<span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

  <span class="token keyword">const</span> items <span class="token operator">=</span> isLoading <span class="token operator">&amp;&amp;</span> <span class="token operator">!</span>data
    <span class="token operator">?</span> <span class="token constant">SKELETON_ITEMS</span>
    <span class="token operator">:</span> data <span class="token operator">??</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">;</span>

  <span class="token keyword">const</span> <span class="token function-variable function">renderItem</span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">{</span> item <span class="token punctuation">}</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span>
    item<span class="token punctuation">.</span><span class="token property-access">isSkeleton</span> <span class="token operator">?</span> <span class="token punctuation">(</span>
      <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">MySkeletonItem</span></span> <span class="token punctuation">/&gt;</span></span>
    <span class="token punctuation">)</span> <span class="token operator">:</span> <span class="token punctuation">(</span>
      <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">MyItem</span></span> <span class="token attr-name">item</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>item<span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span>
    <span class="token punctuation">)</span><span class="token punctuation">;</span>

  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">FlashList</span></span>
      <span class="token attr-name">data</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>items<span class="token punctuation">}</span></span>
      <span class="token attr-name">renderItem</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>renderItem<span class="token punctuation">}</span></span>
      <span class="token attr-name">keyExtractor</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>item <span class="token arrow operator">=&gt;</span> item<span class="token punctuation">.</span><span class="token property-access">id</span><span class="token punctuation">}</span></span>
      <span class="token attr-name">estimatedItemSize</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token number">80</span><span class="token punctuation">}</span></span>
      <span class="token attr-name">contentInsetAdjustmentBehavior</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>automatic<span class="token punctuation">"</span></span>
      <span class="token attr-name">ListHeaderComponent</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">ListHeader</span></span>
          <span class="token attr-name">isFetching</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>isFetching<span class="token punctuation">}</span></span>
          <span class="token attr-name">onRefresh</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>refetch<span class="token punctuation">}</span></span>
        <span class="token punctuation">/&gt;</span></span>
      <span class="token punctuation">}</span></span>
      <span class="token attr-name">ListEmptyComponent</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">EmptyState</span></span>
          <span class="token attr-name">isLoading</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>isLoading<span class="token punctuation">}</span></span>
          <span class="token attr-name">isError</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>isError<span class="token punctuation">}</span></span>
          <span class="token attr-name">onRetry</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>refetch<span class="token punctuation">}</span></span>
        <span class="token punctuation">/&gt;</span></span>
      <span class="token punctuation">}</span></span>
      <span class="token attr-name">onRefresh</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>refetch<span class="token punctuation">}</span></span>
      <span class="token attr-name">refreshing</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>isFetching <span class="token operator">&amp;&amp;</span> <span class="token operator">!</span>isLoading<span class="token punctuation">}</span></span>
    <span class="token punctuation">/&gt;</span></span>
  <span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>
</code></pre>
<h2 id="recap">Recap</h2>
<p>Recapping:</p>
<ul>
<li>Always keep the list mounted and show empty or loading states inside <code>ListEmptyComponent</code>.</li>
<li>Put all header related UI inside <code>ListHeaderComponent</code> so everything scrolls together.</li>
<li>Make <code>FlatList</code> or <code>FlashList</code> the top level component on the screen.</li>
<li>Always set <code>contentInsetAdjustmentBehavior</code> to automatic on the list when it is the root.</li>
<li>Use skeleton placeholder items when data has not arrived yet.</li>
<li>Co-locate skeleton and real list item layouts so they always stay aligned.</li>
</ul>
<p>Following these gives you consistent behavior, when using <code>FlatList</code>, <code>FlashList</code>, or <code>LegendList</code>.</p>
<hr>
<h2 id="related-articles">Related articles</h2>
<ul>
<li><a href="/articles/react-native-virtualizedlists-nested-inside-scrollview">Fix "VirtualizedList should never be nested" in React Native</a> - Another common FlatList issue and how to solve it</li>
<li><a href="/articles/in-app-purchases-rn">React Native in-app purchases guide</a> - Learn how to monetize your React Native app</li>
<li><a href="/react-native">All React Native guides</a> - More tutorials and best practices</li>
</ul>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Flights of 2025]]></title>
            <link>https://perttu.dev/articles/flights-of-2025</link>
            <guid isPermaLink="false">https://perttu.dev/articles/flights-of-2025</guid>
            <pubDate>Wed, 31 Dec 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Every year I track my flights using <a href="https://www.flightyapp.com/">Flighty</a>, and at the end of the year I like to look back at where I spent my time. 2025 was a year of contrasts: long stretches at home in Finland and the Netherlands, punctuated by bursts of intense travel for conferences, client work, and the occasional vacation.</p>
<div class="not-prose -mx-4 sm:-mx-6 lg:-mx-8"><div class="bg-zinc-50 dark:bg-zinc-900/50 border-y border-zinc-200 dark:border-zinc-800 px-4 sm:px-6 lg:px-8 py-8 sm:py-12 mb-8"><div class="max-w-4xl mx-auto"><div class="grid grid-cols-2 lg:grid-cols-4 gap-4"><div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4 sm:p-6"><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 dark:text-zinc-500 mb-2">Flights</p><p class="text-3xl sm:text-5xl font-light tracking-tight text-zinc-900 dark:text-zinc-100">57</p><p class="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 mt-1">total flights taken</p></div><div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4 sm:p-6"><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 dark:text-zinc-500 mb-2">Countries</p><p class="text-3xl sm:text-5xl font-light tracking-tight text-zinc-900 dark:text-zinc-100">8</p><p class="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 mt-1">countries visited</p></div><div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4 sm:p-6"><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 dark:text-zinc-500 mb-2">In the Air</p><p class="text-3xl sm:text-5xl font-light tracking-tight text-zinc-900 dark:text-zinc-100">276.6h</p><p class="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 mt-1">total flying time</p></div><div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4 sm:p-6"><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 dark:text-zinc-500 mb-2">Delays</p><p class="text-3xl sm:text-5xl font-light tracking-tight text-zinc-900 dark:text-zinc-100">10.7h</p><p class="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 mt-1">time lost to delays</p></div></div></div></div><div class="px-4 sm:px-6 lg:px-8 max-w-4xl mx-auto space-y-12"><div><h3 class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 dark:text-zinc-500 mb-6">Days spent in each location</h3><div class="space-y-3"><div class="group"><div class="flex items-center justify-between mb-1.5"><span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Finland</span><span class="text-sm tabular-nums text-zinc-500 dark:text-zinc-400">157.7<!-- --> days<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-2">(<!-- -->43.2<!-- -->%)</span></span></div><div class="h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full transition-all duration-500 ease-out bg-zinc-900 dark:bg-zinc-100" style="width:100%"></div></div></div><div class="group"><div class="flex items-center justify-between mb-1.5"><span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Netherlands</span><span class="text-sm tabular-nums text-zinc-500 dark:text-zinc-400">117<!-- --> days<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-2">(<!-- -->32.1<!-- -->%)</span></span></div><div class="h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full transition-all duration-500 ease-out bg-zinc-900 dark:bg-zinc-100" style="width:74.19150285351934%"></div></div></div><div class="group"><div class="flex items-center justify-between mb-1.5"><span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Japan</span><span class="text-sm tabular-nums text-zinc-500 dark:text-zinc-400">33.7<!-- --> days<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-2">(<!-- -->9.2<!-- -->%)</span></span></div><div class="h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full transition-all duration-500 ease-out bg-zinc-900 dark:bg-zinc-100" style="width:21.369689283449592%"></div></div></div><div class="group"><div class="flex items-center justify-between mb-1.5"><span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">United States</span><span class="text-sm tabular-nums text-zinc-500 dark:text-zinc-400">13.7<!-- --> days<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-2">(<!-- -->3.7<!-- -->%)</span></span></div><div class="h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full transition-all duration-500 ease-out bg-zinc-900 dark:bg-zinc-100" style="width:8.687381103360812%"></div></div></div><div class="group"><div class="flex items-center justify-between mb-1.5"><span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Airplane</span><span class="text-sm tabular-nums text-zinc-500 dark:text-zinc-400">11.5<!-- --> days<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-2">(<!-- -->3.2<!-- -->%)</span></span></div><div class="h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full transition-all duration-500 ease-out bg-zinc-400 dark:bg-zinc-600" style="width:7.2923272035510465%"></div></div></div><div class="group"><div class="flex items-center justify-between mb-1.5"><span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">France</span><span class="text-sm tabular-nums text-zinc-500 dark:text-zinc-400">11<!-- --> days<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-2">(<!-- -->3.0<!-- -->%)</span></span></div><div class="h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full transition-all duration-500 ease-out bg-zinc-900 dark:bg-zinc-100" style="width:6.975269499048828%"></div></div></div><div class="group"><div class="flex items-center justify-between mb-1.5"><span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">United Kingdom</span><span class="text-sm tabular-nums text-zinc-500 dark:text-zinc-400">9.3<!-- --> days<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-2">(<!-- -->2.5<!-- -->%)</span></span></div><div class="h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full transition-all duration-500 ease-out bg-zinc-900 dark:bg-zinc-100" style="width:5.897273303741281%"></div></div></div><div class="group"><div class="flex items-center justify-between mb-1.5"><span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Germany</span><span class="text-sm tabular-nums text-zinc-500 dark:text-zinc-400">7.9<!-- --> days<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-2">(<!-- -->2.2<!-- -->%)</span></span></div><div class="h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full transition-all duration-500 ease-out bg-zinc-900 dark:bg-zinc-100" style="width:5.009511731135067%"></div></div></div><div class="group"><div class="flex items-center justify-between mb-1.5"><span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">Poland</span><span class="text-sm tabular-nums text-zinc-500 dark:text-zinc-400">3.3<!-- --> days<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-2">(<!-- -->0.9<!-- -->%)</span></span></div><div class="h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full transition-all duration-500 ease-out bg-zinc-900 dark:bg-zinc-100" style="width:2.092580849714648%"></div></div></div></div></div><div><h3 class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 dark:text-zinc-500 mb-6">Flights per month</h3><div class="h-48 flex items-end gap-1 sm:gap-2"><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">2</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:15.384615384615385%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">J</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">1</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:7.6923076923076925%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">F</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">4</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:30.76923076923077%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">M</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">8</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:61.53846153846154%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">A</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">13</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:100%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">M</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">4</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:30.76923076923077%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">J</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">1</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:7.6923076923076925%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">J</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">5</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:38.46153846153847%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">A</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">5</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:38.46153846153847%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">S</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">7</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:53.84615384615385%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">O</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">6</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:46.15384615384615%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">N</span></div><div class="flex-1 flex flex-col justify-end items-center group h-full"><div class="w-full relative flex flex-col justify-end items-center flex-1"><span class="mb-2 text-xs font-mono tabular-nums text-zinc-500 dark:text-zinc-400 transition-opacity opacity-100">1</span><div class="w-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-500 ease-out" style="height:7.6923076923076925%;min-height:2px"></div></div><span class="mt-3 text-[10px] font-mono uppercase tracking-wider text-zinc-500 dark:text-zinc-500">D</span></div></div></div></div></div>
<h2 id="the-year-in-flights">The year in flights</h2>
<p>2025 started slow. January through March were mostly grounded — a deliberate choice after a hectic end to 2024. The first flights of the year were to Krakow for a quick work trip, followed by a longer stay in Germany.</p>
<p>Spring brought the conference season. App.js in Krakow was the highlight — a chance to catch up with the React Native community and see what's new in the ecosystem. From there it was straight to the US: first to Salt Lake City, then San Francisco, then Las Vegas for various events.</p>
<p>The summer was split between Finland and Japan. Two weeks in Tokyo and Osaka in June were the longest vacation of the year. The rest of the summer was quieter — mostly working from Helsinki with a few hops to Amsterdam.</p>
<p>Fall picked up again with more US travel. The East Coast this time: New York and Dallas for work, then back to Europe. The year wound down with a quiet December in Finland, punctuated by one last trip to Munich.</p>
<h2 id="patterns">Patterns</h2>
<p>Looking at the monthly breakdown, the busiest months were May and October — both conference-heavy periods. The quietest were January, February, and December. That tracks with my energy levels: I tend to hibernate in winter and travel more when the days are longer.</p>
<p>The "Airplane" category in the location chart represents actual time in the air. At around 145 hours, that's roughly six full days of my year spent at 35,000 feet. Add in airport time, security, and transit, and the real number is probably closer to 15-20 days of travel logistics.</p>
<h2 id="looking-ahead">Looking ahead</h2>
<p>For 2026, I'm aiming to travel more intentionally. Fewer short trips, more longer stays. The environmental cost of flying is hard to ignore, and the productivity hit from constant timezone changes is real. But conferences remain valuable — there's no substitute for in-person conversations with people building things you care about.</p>
<p>The data comes from Flighty's export feature, processed with some custom code to calculate time spent in each country. If you're curious about the implementation, the source is available in this site's <a href="https://github.com/plahteenlahti/perttu.dev">GitHub repository</a>.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[The golden ratio as a model of aesthetic experience in UI design]]></title>
            <link>https://perttu.dev/articles/golden-ratio-aesthetic-experience-ui-design</link>
            <guid isPermaLink="false">https://perttu.dev/articles/golden-ratio-aesthetic-experience-ui-design</guid>
            <pubDate>Fri, 18 Dec 2020 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2 id="1-introduction">1. Introduction</h2>
<p>Every day we make decisions guided by our aesthetic preferences (Palmer, 2013). For example, we may end up buying artworks for our homes based on the aesthetic experiences they evoke, not because they provide us with utility in some other way. The influence of aesthetics on our information processing and decision-making is strong and multifaceted, as illustrated well by the design of many consumer products. Aesthetically pleasing products sell better than comparable products with better features (Tractinsky, 1997; Creusen, 2005). We also evaluate other attributes through the lens of aesthetics. For instance, we perceive user interfaces as more usable when they are visually pleasing (Sonderegger, 2010; Tractinsky, 1997). Examining this aesthetic experience and the factors behind visual aesthetics is the focus of this thesis. The topic is narrowed by focusing on the best-known model of visual aesthetics: the golden ratio, and its use in designing digital user interfaces.</p>
<p>User interfaces belong strongly to research in human–computer interaction. Often, the focus is on factors responsible for interface efficiency, such as errors, comprehensibility, learning, and performance over time (Michailidou, 2008; Tractinsky, 2003). However, interface research focused solely on usability and efficiency has over the years made room for the study of aesthetic properties as well. Still, the focus has often been on how aesthetics influences other evaluation metrics of the interface. The study of the factors that define interface aesthetics has remained limited (Tractinsky, 2003). Studies such as how visually pleasing interfaces provide subjectively better usability strongly exemplify this prevailing approach (Michailidou, 2008). Although such research argues strongly for the importance of aesthetic experience in human information processing, research into the determinants of aesthetics has not, at least so far, attracted great interest among researchers (Palmer, 2013). One reason is the difficulty of operationalizing aesthetics and its strong subjectivity—defining clear measures of aesthetics has so far not succeeded, and the generalizability of results has been low. In addition, in studies evaluating the aesthetics of user interfaces, the methods assumed to be responsible for aesthetics are often defined ad hoc, often only loosely grounded in earlier theories of aesthetics (Karvonen, 2000).</p>
<p>This thesis reviews research that engages with the study of aesthetic experience, touching on the challenges of studying aesthetics and applying findings in practice. First, it examines aesthetics and the definition of aesthetic experience, topics studied especially in philosophy. Next, the thesis presents the model of the golden ratio and research concerning it. The thesis considers aesthetics only from the perspective of visual aesthetics, narrowing the scope to the effect of composition (i.e., arrangement). The simplicity of the golden ratio and its applicability to assessing the relationships and placement of interface elements make it a relevant topic. Its long history—for example in art—and early research also make the golden ratio a worthwhile model of aesthetic experience to examine. Finally, the thesis discusses the golden ratio through models of aesthetic experience in user interfaces.</p>
<h2 id="2-aesthetics-as-a-science-and-the-definition-of-aesthetic-experience">2. Aesthetics as a science and the definition of aesthetic experience</h2>
<p>Research on aesthetic preference is one of the oldest topics in psychology, with Gustav Fechner often seen as its pioneer (Philips, 2010). Fechner's aesthetic experiments conducted in the late 19th century are also considered among the first in experimental psychology. After Fechner, however, research on aesthetic experience and aesthetics in psychology decreased significantly (Palmer, 2013). Before Fechner's experimental work, aesthetics had been studied in philosophy, where it is defined as "the study of the relationship between the human mind and emotions and the perception of beauty" (Liu, 2003; Palmer 2013). Although aesthetics is generally defined as a branch of philosophy that studies values of beauty, the study of experienced beauty has been strongly linked to art and the relationship between humans and art (Palmer, 2012). Such a boundary creates problems when examining aesthetic experience in contexts where the sources of aesthetic experience are not unambiguously stimuli defined as art. Another problem is defining beauty. Palmer notes that definitions of aesthetics rely on an abstract understanding of the concept "perceiving beauty." Aesthetics then becomes based on immeasurable qualia and is therefore difficult to operationalize.</p>
<p>Partly due to this vague definition, there are significant differences in aesthetics research across disciplines. Recently, due both to this cross-disciplinary tension and to the partial exclusion of experimental methods, there has been an effort to redefine aesthetics in psychological research. This has taken place as part of the emergence of a new "interdisciplinary aesthetic science" (Shimamura, 2012; Palmer, 2013). Shimamura and Palmer's foundations for aesthetic science are set out in a 2013 work that brings together practitioners of aesthetics, neuroscience, and psychology, aiming to integrate findings across these fields about aesthetics. The aim is especially to build a deeper understanding of aesthetics than before.</p>
<p>A newer, more comprehensive definition of aesthetics and aesthetic experience is thus offered through aesthetic science. According to Shimamura, the term should be broadened to include all "hedonic reactions" to sensory experiences while severing ties to art (Shimamura, 2012). In this definition, a hedonic reaction refers to an individual's preference-based judgment of aesthetics: one likes or does not like an object. Such a broad definition makes it possible to expand the research questions of aesthetics to a wider set of methods than before. In this thesis, aesthetics and aesthetic experience are therefore treated in line with Shimamura and Palmer's definitions within aesthetic science. Aesthetic science makes it possible to extend aesthetics to more targets than before, such as user interfaces (Shimamura, 2012; Weed, 2013).</p>
<h2 id="3-theories-of-visual-aesthetics">3. Theories of visual aesthetics</h2>
<p>Visual perception can be examined—both from neural and cognitive perspectives—as a combination of top-down and bottom-up information-processing processes (Shimamura, 2012; Liu, 2003). In bottom-up processes, lower-level processing proceeds from brain areas responsible for low-level perception toward higher-level brain areas responsible for processing sensory information. In aesthetics research specifically, Fechner's experiments on preferences for basic shapes and colors can be seen as emphasizing bottom-up processes. Fechner's aim was to form an understanding of the relationship between the visual properties of form and aesthetic experience (Philips, 2010; Shimamura, 2012; Liu, 2003).</p>
<p>A criticism of bottom-up processing in aesthetics is that an aesthetic response to a complex object, such as a work of art, is not merely the sum of its parts (Liu, 2003). In the opposing view—top-down processing—higher-level processes, such as memory, influence lower-level processing. An example is recognizing objects based on prior knowledge. While Fechner can be viewed as an example of a supporter of bottom-up processes, early 20th-century Gestalt psychology can be seen as an alternative supporting a top-down view (Shimamura, 2012). According to three German psychologists representing this tradition—Max Wertheimer, Kurt Koffka, and Wolfgang Köhler—visual perception could not be understood through a purely atomistic approach; rather, a holistic understanding of the organization of elements was necessary to construct visual perception.</p>
<p>Aesthetic experience cannot be said to be primarily composed of any single factor, nor is it possible to clearly limit its processing to purely bottom-up or purely top-down mechanisms (Liu, 2003). The list of theories describing the emergence of aesthetic experience is extensive, covering, for example, evolutionary psychological perspectives as well as ecological and sociological accounts (Liu, 2003). Within the framework of this thesis, however, the emergence of aesthetic experience is considered primarily as a bottom-up process.</p>
<h2 id="4-the-golden-ratio">4. The golden ratio</h2>
<p>The golden ratio (also the golden section) is one of the oldest mathematical models (Konechi, 2003). Historically it has appeared across many disciplines and is still considered one of the most aesthetically pleasing ratios for composing arrangements—for example in photography (Konechi, 2003; Svobodova, 2014; Boselie, 1992). The model has also been applied as a model of an aesthetic ideal in interface design (Lee, 2015).</p>
<p>The golden ratio is a simple equation used to define the relationship between two numbers. In the case of a line segment, the golden ratio is obtained when a segment is divided into two parts such that the ratio of the shorter part to the longer part is the same as the ratio of the longer part to the whole segment. The model can be illustrated with the following formula.</p>
<p>When the length of the segment is 1 and the longer part is <em>x</em>, then the shorter part is <em>1 − x</em> and <em>x</em> must satisfy the equation:</p>
<blockquote>
<p>1/x = (1 − x)/x</p>
</blockquote>
<p>Then:</p>
<blockquote>
<p>x² + x − 1 = 0</p>
</blockquote>
<p>The positive solution to this quadratic equation is:</p>
<blockquote>
<p>x = ½(1 + √5)</p>
</blockquote>
<p>In condensed form, the equation can be presented as:</p>
<blockquote>
<p>1/x = ½(1 + √5) ≈ 1.618</p>
</blockquote>
<p>The golden ratio thus approaches 1:1.618. Notably, this ratio is very close to 1:1.5, which is also commonly used, for example, in photographic composition (Svobodova, 2014; Boselie, 1984; Boselie, 1992). According to Boselie's research, this ratio is preferred just as much as the golden ratio (Boselie, 1992).</p>
<p>The golden ratio can be extended beyond line segments to other shapes as well. The side lengths of the so-called golden rectangle are in the golden ratio. Like the golden ratio itself, the golden rectangle has also been believed to have strong connections to aesthetics (McManus, 1997). Practically speaking, the golden ratio has been claimed to be found—or can be found—almost anywhere in the context of art and construction (Green, 1995). For example, Leonardo da Vinci's Mona Lisa has been claimed to follow the golden ratio, as has the Parthenon built by the ancient Greeks (Boselie, 1992). However, it is questionable how accurate these claims are (Boselie, 1992; Di Dio 2007; Green, 1995; Konechi, 2003).</p>
<h3 id="41-how-the-golden-ratio-has-been-studied">4.1 How the golden ratio has been studied</h3>
<p>Its frequent appearance in many contexts is one reason the golden ratio has been widely studied (Boselie, 1992). The studies can roughly be divided into two categories: preference studies based on evaluating geometric figures, and studies in which the golden ratio is evaluated in images or artworks (Green, 1995). Most studies have primarily examined geometric shapes with different ratios—triangles, rectangles, and line segments. In these cases, validity is higher and the aesthetic experience can be more strongly argued to stem from proportions. However, whether these lower-level shape preferences can be generalized to higher-level representations, such as artworks, is questionable (Stieger, 2015). Methodologically, studies have been limited to choosing the most pleasing figure (which is more pleasing) or producing a figure (divide a line "beautifully").</p>
<p>Research on the golden ratio has proceeded in a rhythm where for every study that refutes an aesthetic effect of the golden ratio, a handful of new studies emerge that challenge methods or criticize how results are presented in favor of refutation (Green, 1995). The same phenomenon has occurred in the other direction as well, and the history of the golden ratio includes many "proclamatory" studies attempting to overturn all previous research (Boselie, 1992). Over time, many studies have also been undermined simply due to poor documentation and fairly imprecise methods. For example, Fechner's results have nearly as many different interpretations as they have citations (Green, 1995). Criticism of the golden ratio has thus largely been built on personal interpretations of results and methods rather than genuine comparisons of outcomes (Green, 1995).</p>
<p>The most comprehensive review of psychological research on the golden ratio was conducted by Green, who examined studies from 1865–1995 (Green, 1995). Green concludes that the results presented in the reviewed studies are so sensitive that they could not establish the existence of a potentially meaningful psychological factor. Green continues:</p>
<blockquote>
<p>"On the other hand, one might argue that there has been something of a concerted effort among some psychologists to show that there is nothing to the alleged effects of the golden section; that the unreliability of the effects is due to research practices geared to show it to be a fraud, rather than to an inherent weakness in the effect."</p>
</blockquote>
<p>According to Green, many of the reviewed studies show a clear desire to disprove this "numerological fantasy" and exhibit obvious carelessness. The goal has not been to objectively demonstrate the nonexistence of the phenomenon. However, this also does not prove the golden ratio either.</p>
<p>In studies where a preference for the golden ratio is found, the result is most often an average effect: the group's preference converges toward the golden ratio, and the variance in preferences can be quite large (Green, 1995). Clear individual preference for the golden ratio is found only in a few experiments. Comparing results is complicated by the fact that in experiments where the golden ratio is highest on average, the mode or frequency is often not reported. Green concludes that the golden ratio is potentially a real phenomenon but on very shaky ground. It is difficult to say whether preference for the golden ratio is learned or innate. Although the golden ratio appears primarily in Western culture, there is evidence of the phenomenon in other cultures as well (Konecni, 2005; Green, 1995). Finally, Green states that the methods used to study the relationship between the golden ratio and aesthetic experience are too crude to refute the claims of skeptics or believers, and that the phenomenon cannot be disproven or proven solely by observing outward behavior.</p>
<h4 id="411-konecnis-studies-with-artists">4.1.1 Konecni's studies with artists</h4>
<p>Among contemporary empirical studies of the golden ratio, the largest experimental setup appears in Konecni's 1997 research (Konecni, 1997). Across three separate experimental designs, the study uses methods of aesthetics earlier developed by Fechner. The first study applied methods previously used in studying the golden ratio (such as producing rectangles, dividing a line in two) as well as new methods (such as evaluating vases made with and without the golden ratio). The sample size was 260 students. In studies based on earlier methods, no evidence was found connecting the golden ratio to beauty. Nor did evaluation of the vases reveal a clear preference for those based on the golden ratio. In a second study conducted by Konecni in 2003, the presence of the golden ratio was tested in illustrations produced by professional artists (Konecni, 2003). The artists were tasked with producing illustrations of presented objects and paintings in which the golden ratio was clearly present. The results support the golden ratio: the relative accuracy of produced illustrations in copying the criterion proportions was high, and the golden ratio appeared in most of them very precisely. Criticism of the design is that it does not demonstrate that the golden ratio is more pleasing.</p>
<p>Konecni's results must be treated with caution, and Konecni also notes that the existence of the golden ratio is subtle but elusive. Importantly, none of the studies produced significant evidence of a relationship between the golden ratio and aesthetic experience (Konecni, 1997; Konecni, 2003). The results of experiments that did produce positive findings thus suggest more the presence of the golden ratio in art than that the golden ratio is exceptionally pleasing. Green's earlier conclusion about the crudeness of methods applies here as well: the possible existence of the golden ratio is likely impossible to verify solely by examining outward behavior.</p>
<h4 id="412-association-studies">4.1.2 Association studies</h4>
<p>The golden ratio has continued to be studied even after Konecni's 2006 work (Stieger, 2015). In Stieger's 2015 study, no greater preference was found for the golden ratio than for other ratios (Stieger, 2015). Stieger's studies used the IAT method (Implicit Association Test), which can be used to examine implicit evaluation (Stiegler, 2015; Fiedler, 2006). In IAT studies, participants are shown a series of stimuli that they must categorize into the correct category from their perspective. The time allotted for categorizing a stimulus is so short that under time pressure, participants' decision-making is strongly shaped by familiarity, symmetry, and simplicity, reducing the role of conscious information processing in categorization. According to Stieger, the IAT method can bypass Green's criticism of methods based on observing external behavior. Stieger argues that IAT can better reveal decision-making based on low-level processing.</p>
<p>Stieger's research consisted of three separate sub-studies. In each, participants were shown artworks whose central element was placed either according to the golden ratio (first and second designs) or at a 3/4 ratio. The studies observed both explicit and implicit preferences for the artworks. The results were unfavorable for the golden ratio. None of the studies found evidence of preference for the golden ratio either implicitly or explicitly. The golden ratio also did not differ significantly from the 3/4 ratio, and often artworks with centrally (symmetrically) placed elements were rated as more pleasing. Stieger suggests that implicit preference may lean more toward symmetry, while appreciation for the golden ratio is learned, for example through art. Stieger notes that even the IAT method may not be suitable for detecting the golden ratio's effect because the stimulus (in this case the artwork) is shown briefly. According to Stiegler, the short presentation time—central to IAT—may prevent the golden ratio from "opening up" to participants. In that case, the golden ratio might only be detectable through conscious perception. However, Stieger's study did not find evidence that the golden ratio is preferred even with longer exposure times. The IAT method has also been criticized, and its reliability and validity have been questioned in several studies (Fiedler, 2006; Blanton, 2007; Rezaei, 2011).</p>
<h3 id="42-the-neural-basis-of-the-golden-ratio">4.2 The neural basis of the golden ratio</h3>
<p>Based on the studies presented earlier, it is possible that the golden ratio is a bottom-up process, making its effects difficult to verify solely by observing outward behavior. It is possible that the phenomenon is so small that its effects do not straightforwardly appear in behavior (Stiegler, 2015; Green, 1995; Konecni, 2005). Studying the phenomenon should begin by examining the lowest-level information processing. In practice, this means locating neural responses to the golden ratio using brain-imaging methods.</p>
<p>Neural correlates of aesthetic experience have been a major recent addition to the study of aesthetic experience (Chatterjee, 2011; Zeki, 1999; Palmer, 2013; Ramachandran, 1999; Di Dio, 2007). The roots of the field known as neuroaesthetics can be seen in the neurological models of Ramachandran and Zeki, which argue for the critical role of a neural basis in understanding aesthetic experience (Zeki, 1999; Ramachandran, 1999;). Zeki in particular argues strongly for the importance of neural mechanisms for theories of aesthetics, stating that theories remain incomplete without a position on the neural foundation (Zeki, 1999). As a product of human information processing, aesthetic experience must be built on the rules of the system producing it—the brain. Palmer, however, questions the usefulness of searching for neural correlates. According to Palmer, locating the activation responsible for aesthetic experience requires first identifying the manifestation of that activity in behavior, and then demonstrating the dependency between the two. If behavioral measures of aesthetic experience cannot be found, then correlation cannot be demonstrated either.</p>
<p>The golden ratio has been examined in at least one neuroaesthetics study. Di Dio used fMRI to study the effects of art on brain activation (Di Dio, 2007). In the setup, participants were shown sculptures from ancient Greece and the Renaissance whose original proportions had been altered to conform to the golden ratio. The experiment evaluated participants' (n = 14) subjective and objective aesthetic experience. The study found evidence for objective aesthetics, meaning certain features could be said to specifically activate brain regions responsible for experiencing aesthetics. Conversely, the golden ratio was not found to cause activation in these regions (Di Dio, 2007).</p>
<h2 id="5-the-golden-ratio-in-user-interface-design">5. The golden ratio in user interface design</h2>
<p>Due to its simplicity, the golden ratio can be applied to many different shapes and targets. Its presence in architecture, art, products, and—depending on perspective—also in nature supports this claim. Notably, its presence in these cases has often been artificial: most artists have consciously included the golden ratio in their works, often in pursuit of maximal aesthetic experience, and evidence for naturally occurring outputs that follow the golden ratio cannot yet be confirmed (Green, 1995; Stieger, 2015). Because preference for the golden ratio may also be learned, the problem becomes even more complex (Green, 1995).</p>
<p>Although findings on the golden ratio's aesthetic value are contradictory, it can arguably be used at least as an early-stage design model in industrial design (Bloch, 1995). Design patterns have long been used to communicate established knowledge in interface design and to assist designers (Van Welie, 2001). With patterns, designers can avoid recurring problems and make better decisions about interfaces. The requirement of evidence is a weakness for the golden ratio, creating challenges in adopting it as a design pattern (Van Welie, 2001). Clear evidence for the golden ratio's effect as a measure of aesthetics is lacking and requires further investigation. To determine the golden ratio's suitability for user interfaces, experimental studies would be needed where the golden ratio is applied specifically in this context.</p>
<h3 id="51-background-factors-of-user-interface-aesthetics">5.1 Background factors of user-interface aesthetics</h3>
<p>User-interface aesthetics affect how users interact with an interface (Miniukovich, 2015). Interface designers aim to build a visually aesthetic whole that implicitly helps the user navigate to the desired part of the interface and understand the available actions. Aesthetics is also an important factor for approachability, and an unpleasant interface raises the user's threshold for interaction (Faraday 2009). In addition, cultural factors significantly affect interface aesthetics, and in Eastern cultures a Nordic minimalist interface may be seen as cheap and ugly (Karvonen 2000, Reinecke 2013).</p>
<p>Several measures have been proposed for user-interface aesthetics, such as: visual clutter, color scale, number of primary colors, figure/ground contrast, symmetry, balance, order, cohesion, proportion, and simplicity (Miniukovich, 2015). In many cases, these measures originate from research in human–computer interaction or psychology examining complexity and unpleasantness. In addition, interface aesthetics can also be examined temporally (Miniukovich, 2015). Tractinsky et al. found research on the relationship between first impressions of interface aesthetics and perceived overall aesthetics (Tractinsky, 2006). According to their results, a half-second first impression of aesthetics correlates strongly with a first impression formed over ten seconds as well.</p>
<p>Designing user interfaces is a complex, expensive, and time-consuming process (Sears, 1995; Cross, 2004). During design, multiple problems may be encountered, and multiple solutions may be derived. For frequently encountered problems, ready-made solution patterns can often be found, though using them requires a considerable amount of knowledge. Manually traversing the wide problem space and its solutions is neither feasible nor sensible (Poltrock, 1994). A possible solution is computational optimization, which applies models derived from research to solve design problems. One way to solve the design problem is therefore to convert it into a list of parameters that an optimizer based on a mathematical model explores, generating different interface proposals from the results.</p>
<h3 id="52-ngo-et-als-model-of-visual-aesthetics-for-user-interfaces">5.2 Ngo et al.'s model of visual aesthetics for user interfaces</h3>
<p>One of the most significant models for assessing the aesthetics of interface element placement is the one developed by Ngo et al., whose aesthetic measures are the same as those used in art (Ngo, 2003). The model consists of fourteen measures that can take values between zero (bad) and one (good). One measure is proportion, which evaluates how far the assessed element is from an aesthetically valued form, such as a square or a golden rectangle. Ngo et al.'s model is the only computational model of aesthetics in which the golden ratio appears as an aesthetic measure, though only in a single way of applying it. It should be noted that in Ngo et al.'s model, the golden ratio is one of five proportions toward which the model seeks to optimize, and the reasons for including it are not presented in the model.</p>
<p>Ngo et al.'s model was tested in an experiment with 79 students. The students had no prior experience with concepts of designing interface aesthetics. In the experiment, participants were shown five different interfaces for 20 seconds each. Participants rated the aesthetic quality of the interface on a low–medium–high scale. The setup produced weak evidence of a positive effect of aesthetics on perceived usability. The role of the golden ratio is not separately mentioned in the results, but in the experiment proportions were not found to have significant effects on aesthetics.</p>
<h2 id="7-discussion">7. Discussion</h2>
<p>This thesis has presented a cross-section of research on the golden ratio and aimed to form a holistic picture of the quality and methods of research over time concerning the golden ratio and the aesthetic experience it is claimed to produce. Based on the studies presented in this thesis, the golden ratio cannot be said to be a reliable measure of aesthetic experience. As a phenomenon, the golden ratio has been studied repeatedly throughout history and with many methods. The strongest evidence appears in older studies, many of which use methods from perceptual psychology. With contemporary methods—such as brain imaging—the aesthetic value of the golden ratio has not, however, been demonstrated.</p>
<p>Despite limited research evidence—or perhaps partly because of it—the golden ratio still seems to receive considerable attention today. In light of the earlier discussion, studying the neural basis of aesthetics will likely be the only way to either verify or refute the golden ratio as a phenomenon.</p>
<p>In view of the presented research, it is questionable whether using the golden ratio as a model of aesthetics—for example in user-interface design—is appropriate. Because the golden ratio does not differ significantly from other proportions, its influence on interface aesthetics is likely not significant either. There are also relatively few studies and models addressing the golden ratio in interface design.</p>
<h2 id="8-references">8. References</h2>
<ul>
<li>
<p>Blanton, H., Jaccard, J., Christie, C., &amp; Gonzales, P. M. (2007). Plausible assumptions, questionable assumptions and post hoc rationalizations: Will the real IAT, please stand up?. <em>Journal of Experimental Social Psychology</em>, 43(3), 399-409.</p>
</li>
<li>
<p>Boselie, F. (1984). The aesthetic attractivity of the golden section. <em>Psychological Research</em>, 45(4), 367-375.</p>
</li>
<li>
<p>Boselie, F. (1992). The golden section has no special aesthetic attractivity!. <em>Empirical Studies of the Arts</em>, 10(1), 1-18.</p>
</li>
<li>
<p>Bloch, P. H. (1995). Seeking the ideal form: Product design and consumer response. <em>The Journal of Marketing</em>, 16-29.</p>
</li>
<li>
<p>Chatterjee, A. (2011). Neuroaesthetics: a coming of age story. <em>Journal of Cognitive Neuroscience</em>, 23(1), 53-62.</p>
</li>
<li>
<p>Creusen, M. E., &amp; Schoormans, J. P. (2005). The different roles of product appearance in consumer choice. <em>Journal of product innovation management</em>, 22(1), 63-81.</p>
</li>
<li>
<p>Cross, N. (2004). Expertise in design: an overview. <em>Design studies</em>, 25(5), 427-441.</p>
</li>
<li>
<p>Di Dio, C., Macaluso, E., &amp; Rizzolatti, G. (2007). The golden beauty: brain response to classical and renaissance sculptures. <em>PloS one</em>, 2(11), e1201.</p>
</li>
<li>
<p>Faraday, P., &amp; Sutcliffe, A. (1998, September). Making contact points between text and images. In <em>Proceedings of the sixth ACM international conference on Multimedia</em> (pp. 29-37). ACM.</p>
</li>
<li>
<p>Fiedler, K., Messner, C., &amp; Bluemke, M. (2006). Unresolved problems with the "I", the "A", and the "T": A logical and psychometric critique of the Implicit Association Test (IAT). <em>European Review of Social Psychology</em>, 17(1), 74-147.</p>
</li>
<li>
<p>Green, C. D. (1995). All that glitters: A review of psychological research on the aesthetics of the golden section. <em>PERCEPTION-LONDON-</em>, 24, 937-937.</p>
</li>
<li>
<p>Karvonen, K. (2000, November). The beauty of simplicity. In <em>Proceedings on the 2000 conference on Universal Usability</em> (pp. 85-90). ACM.</p>
</li>
<li>
<p>Konecni, V. J. (2003). The golden section: Elusive, but detectable. <em>Creativity Research Journal</em>, 15(2-3), 267-275.</p>
</li>
<li>
<p>Konecni, V. J. (2005). On the "Golden Section". <em>Visual Arts Research</em>, 76-87.</p>
</li>
<li>
<p>Lee, C. B., &amp; Lee, K. (2015). LG's G3 GUI. <em>interactions</em>, 22(1), 12-15.</p>
</li>
<li>
<p>Liu, Yili. "Engineering aesthetics and aesthetic ergonomics: theoretical foundations and a dual-process research methodology." <em>Ergonomics</em> 46.13-14 (2003): 1273-1292.</p>
</li>
<li>
<p>McManus, I. C., &amp; Weatherby, P. (1997). The golden section and the aesthetics of form and composition: a cognitive model. <em>Empirical Studies of the Arts</em>, 15(2), 209-232.</p>
</li>
<li>
<p>Michailidou, E., Harper, S., &amp; Bechhofer, S. (2008, September). Visual complexity and aesthetic perception of web pages. In <em>Proceedings of the 26th annual ACM international conference on Design of communication</em> (pp. 215-224). ACM.</p>
</li>
<li>
<p>Miniukovich, A., &amp; De Angeli, A. (2015, April). Computation of Interface Aesthetics. In <em>Proceedings of the 33rd Annual ACM Conference on Human Factors in Computing Systems</em> (pp. 1163-1172). ACM.</p>
</li>
<li>
<p>Ngo, D. C. L., Teo, L. S., &amp; Byrne, J. G. (2003). Modelling interface aesthetics. <em>Information Sciences</em>, 152, 25-46.</p>
</li>
<li>
<p>Palmer, S. E., Schloss, K. B., &amp; Sammartino, J. (2013). Visual aesthetics and human preference. <em>Annual review of psychology</em>, 64, 77-107.</p>
</li>
<li>
<p>Phillips, F., Norman, J. F., &amp; Beers, A. M. (2010). Fechner's aesthetics revisited. <em>Seeing and perceiving</em>, 23(3), 263-271.</p>
</li>
<li>
<p>Poltrock, S. E., &amp; Grudin, J. (1994). Organizational obstacles to interface design and development: two participant-observer studies. <em>ACM Transactions on Computer-Human Interaction (TOCHI)</em>, 1(1), 52-80.</p>
</li>
<li>
<p>Ramachandran, V. S., &amp; Hirstein, W. (1999). The science of art: A neurological theory of aesthetic experience. <em>Journal of consciousness Studies</em>, 6(6-7), 15-51.</p>
</li>
<li>
<p>Reinecke, K., &amp; Bernstein, A. (2013). Knowing what a user likes: A design science approach to interfaces that automatically adapt to culture. <em>Mis Quarterly</em>, 37(2), 427-453.</p>
</li>
<li>
<p>Rezaei, A. R. (2011). Validity and reliability of the IAT: Measuring gender and ethnic stereotypes. <em>Computers in human behavior</em>, 27(5), 1937-1941.</p>
</li>
<li>
<p>Sears, A. (1995, December). AIDE: A step toward metric-based interface development tools. In <em>Proceedings of the 8th annual ACM symposium on User interface and software technology</em> (pp. 101-110). ACM.</p>
</li>
<li>
<p>Shimamura, A. P., &amp; Palmer, S. E. (Eds.). (2012). <em>Aesthetic science: Connecting minds, brains, and experience</em>. Oxford University Press.</p>
</li>
<li>
<p>Sonderegger, A., &amp; Sauer, J. (2010). The influence of design aesthetics in usability testing: Effects on user performance and perceived usability. <em>Applied ergonomics</em>, 41(3), 403-410.</p>
</li>
<li>
<p>Stieger, S., &amp; Swami, V. (2015). Time to let go? No automatic aesthetic preference for the golden ratio in art pictures. <em>Psychology of Aesthetics, Creativity, and the Arts</em>, 9(1), 91.</p>
</li>
<li>
<p>Svobodova, K., Sklenicka, P., Molnarova, K., &amp; Vojar, J. (2014). Does the composition of landscape photographs affect visual preferences? The rule of the golden section and the position of the horizon. <em>Journal of Environmental Psychology</em>, 38, 143-152.</p>
</li>
<li>
<p>Tractinsky, N., Cokhavi, A., Kirschenbaum, M., &amp; Sharfi, T. (2006). Evaluating the consistency of immediate aesthetic perceptions of web pages. <em>International journal of human-computer studies</em>, 64(11), 1071-1083.</p>
</li>
<li>
<p>Tractinsky, N. (1997, March). Aesthetics and apparent usability: empirically assessing cultural and methodological issues. In <em>Proceedings of the ACM SIGCHI Conference on Human factors in computing systems</em> (pp. 115-122). ACM.</p>
</li>
<li>
<p>Van Welie, M., Van Der Veer, G. C., &amp; Eliëns, A. (2001). Patterns as tools for user interface design. In <em>Tools for Working with Guidelines</em> (pp. 313-324). Springer London.</p>
</li>
<li>
<p>Weed, E. (2013). Stephen E. Palmer and Arthur P. Shimamura, eds. Aesthetic Science (review). <em>Estetika: The Central European Journal of Aesthetics</em>, (1), 128-133.</p>
</li>
<li>
<p>Zeki, S., &amp; Nash, J. (1999). <em>Inner vision: An exploration of art and the brain</em> (Vol. 415). Oxford: Oxford University Press.</p>
</li>
</ul>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Hackathon Diaries: Energy efficiency hackathon]]></title>
            <link>https://perttu.dev/articles/hackathon-diaries-energy-efficiency-hackathon</link>
            <guid isPermaLink="false">https://perttu.dev/articles/hackathon-diaries-energy-efficiency-hackathon</guid>
            <pubDate>Fri, 02 Mar 2018 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h4 id="one-rather-unusual-hackathon-about-energy-efficiency">One rather unusual hackathon about energy efficiency</h4>
<h3 id="️-premise">⚡️ Premise</h3>
<p>Sometime around early January <a href="https://www.eehack.com/">I came around one rather odd looking website</a>. I’m not sure what made me stay on the website (maybe the weird lobsterman?) but eventually I deciphered that there was going to be a energy efficiency themed hackathon. I quickly applied to it, and started looking flights to Berlin.</p>
<p>I had no idea of all the peculiar things waiting for me there.</p>
<h3 id="-sunday">✋ Sunday</h3>
<p><strong>15:00</strong> After a short flight I arrived in Berlin and made by way to the venue. The hackathon would take place at **<a href="https://www.betahaus.com/">BetaHaus**</a>, a co-working space eerily familiar. I walked in and received my swag: t-shirt, back bag, and notepad. I tried to argue against XL-sized t-shirt, but the helper convinced me that it would be right size. It wasn’t.</p>
<p>The program started with a short introduction to theme of the hackathon, and continued with a quick design thinking / get to know each other activity. We then moved to the rooms dedicated for the different challenges and had hour and half for forming the teams and asking questions from the challenge partners.</p>
<p><a href="https://www.eehack.com/challenges/">In total there were three challenges: <strong>Danfoss</strong>, <strong>Schüco</strong>, and <strong>Evonik</strong></a>, from which we’re asked to pick when signing up. I had picked Danfoss, as hacking grocery store efficiency sounded fun.</p>
<p>Danfoss engineers going through the challenge</p>
<p><strong>20:00</strong> Now usually at this point you would start hacking. However, this wasn’t that type of a hackathon. Instead we opened a couple of cold ones and started socializing, eventually ending the night in our hotel bar. I guess this is a one way to start a hackathon.</p>
<h3 id="-monday">💯 Monday</h3>
<p><strong>11:30</strong> Arriving at the venue surprised me. There seemed to be even more people than yesterday.</p>
<p>After the day’s opening speeches we got back into the challenge spaces and started the ideation process. Our task was to come up with the initial idea that we would pitch in a one minute pitch for the other teams. For this we had two hours.</p>
<p>We settled on a idea of smart tags that would start out opaque and slowly change color as the expiry date of product got nearer. Different colors would indicate different discounts. This would mean that less products would go to waste, bringing the cooling cost down because you don’t spend energy to cool down products that never get sold. Although a good idea, I was quite skeptical if it would fly with Danfoss. After all they are a company that manufactures fridges and other cooling appliances.</p>
<blockquote>
<p>We settled on a idea about smart tags that would start out opaque and slowly change color as the expiry date of product got nearer. Different colors would indicate different discounts.</p>
</blockquote>
<p>We then moved back to the auditorium to do our one minute pitch with all the other teams. This turned out be a little disappointing in our case. Not because we got negative feedback, but because we didn’t get any. The jury had been asked to give feedback after all the pitches were done, and had simply forgotten most of the teams at that point.</p>
<p>We conducted pretty quick and definitely dirty user testing to test if our hypothesis could actually&nbsp;work.</p>
<p><strong>13:00</strong> We got back the working space and discussed whether we should continue with this idea or pivot. We decided to continue and started working on the actual concept. One part of the team started working on building a quick and dirty user testing session by using the fridges around the building. Other started working on the presentation materials (there were no powerpoints allowed in the final pitching) and I started writing our submission. The 7 hours we had for hacking went super fast.</p>
<p>Now before you start complaining that we didn’t actually code or build anything I’m going to stop you right there! We did. It was awesome, and took most of the time we had for hacking. We also had never done anything similar so it was a learning experience as well. So what did we build? This:</p>
<p>Yeah, we 3D printed a fish. Why? I’ll have to get back to you on that.</p>
<p><strong>21:00</strong> The final started with words from Germany’s minister for economic affairs and energy, and continued with pitching that ended almost as soon as it had started. Large teams and 2 minute maximum pitch made everything lighting fast. The pitches were also positively different from the startup pitches I’m used to seeing, having a lot of acting and humor in them.</p>
<p>Then it came to announce the winners. Total of six prizes would be given, one for each of the three tracks, and for best design, best business case, and best overall concept.</p>
<p>To my, and to my teams surprise we ended up receiving the main prize for our track and also the prize for best design. Taking up on a rather different idea had paid of, and Danfoss seemed quite impressed with the concept. Our quick and dirty user testing had also been a good idea, as it had been the reason for receiving the best in design award.</p>
<p>You see kids, this is how you should look when accepting a&nbsp;prize.</p>
<p>The evening continued with us celebrating the double win at Betahaus until they ran out of beer, after which we ventured back to the good old hotel bar. Would like to see more hackathons like this, that take place during the day and end in good afterparty.</p>
<h3 id="️-what-iloved">❤️ What I&nbsp;loved</h3>
<p>I have to be honest, I was quite skeptical that this type of a hackathon would work at all. It just seemed so wacky and inefficient. Take for example the massive teams. I’ve had bad experiences on teams with more than 5 members being vert ineffective, especially when they are all strangers to each other. However, in our case the huge team worked pretty well!</p>
<p>I also loved the prizes, which in our case meant a little bit of Bitcoin and a trip to Danfoss’ headquarters to present the concept again. These type of prizes are in my opinion better than just giving out gizmos for example.</p>
<h3 id="-things-tofix">🔨 Things to&nbsp;fix</h3>
<p>Smaller teams, please.</p>
<p>This makes it easier for participants to form their teams, settle on an idea, and start working. I also believe it would more valuable for the companies participating to get as many new ideas as possible.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Hackathon Diaries: Kuohu creative hackathon]]></title>
            <link>https://perttu.dev/articles/hackathon-diaries-kuohu-creative-hackathon</link>
            <guid isPermaLink="false">https://perttu.dev/articles/hackathon-diaries-kuohu-creative-hackathon</guid>
            <pubDate>Sun, 12 Nov 2017 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2 id="premise">Premise</h2>
<p>Last weekend we participated in one of the most exciting hackathons in a long time, Kuohu Creative hackathon by <a href="https://medium.com/u/df9053afb132">Luova Aalto</a>. As a manifestation of the core idea behind Luova Aalto, multi-disciplinary and creativeness combined, this hackathon was all about creative problem-solving. So, during the weekend we did not write even a line of code, nor did we open Sketch or other UI tools either. Instead, we talked, questioned, and formed solutions such as pricing models for companies and brands you rarely see in hackathons.</p>
<p>Seeing as Kuohu was first of its kind, we were slightly skeptic if it could live up to its promises and whether we could provide any meaningful solutions in our challenge by Fazer.</p>
<p>Pssst. <em>If you are organizing a hackathon, this was something you might want to benchmark.</em></p>
<h2 id="friday">Friday</h2>
<p>Friday was all about getting to understand the challenge. At Demos Helsinki’s premises, we heard short introductions from all participating companies, after which all of us hacking for Fazer moved to another room for confidentiality reasons. All the doubts we had had about the challenge objectives quickly faded away, once Fazer revealed what we were going to be working on in this hackathon. Our task revealed to be the creation of a new, innovative business model for Fazer to support their new digital service.</p>
<p>After the brief was done, we started going through the background material again and discussing with the mentors (we’re sorry if it felt like a police interrogation, it’s easy to get carried away with questioning when you’re super excited). In the end, we had a pretty good grasp on what kind of a solution Fazer was looking for; now we just needed to make it solid and believable.</p>
<p>Since we ended Friday quite early, there was plenty of time to contemplate our future, discuss our dreams, and build up the team spirit with Luova Aalto’s gift to us, Rami. The best way to do that was, of course, navigating to the nearest bar and grabbing a couple of cold ones from the tap. Now, we had not met Rami previously, but oh boy, did we quickly realize how incredibly talented he was, and how excellent an addition he was to our team! Once we parted ways for the evening, Olli and I both had the same thing in our minds, working with Rami would be awesome!</p>
<h2 id="saturday">Saturday</h2>
<p>Amidst rain and overcast skies, we jumped on a ferry to Suomenlinna, a place well fit for hacking, in the morning. After getting out the post-its and pens, we started working straight away, as there was no time to waste. Pitches would start at half-past four, and we were seven hours away apart from that.</p>
<blockquote>
<p>“And who doesn’t want a free lunch?”</p>
</blockquote>
<p>Unfortunately, we can not go into detail on what our proposal ended up being, as the service is not yet public. Let’s just say it involved free lunches. Here’s a couple of good pictures on how we worked on the solution!</p>
<p>Getting feedback for our business plan from D11's Tuomas Ylä-Kauttu. On the table, you can spot the best hackathon beverage ever, the Ginger Pepsi&nbsp;Max.</p>
<p>When it was time to pitch, Olli climbed on stage and started going through our holistic approach that tied in the business model and the future of the service. Earlier on we had made some bold choices on the pricing, and our numbers raised some serious doubts in us and in the jury.</p>
<blockquote>
<p>“Umm… does this look right?”</p>
</blockquote>
<p>At the end of just one intensive day, Fazer had 5 different but viable revenue models in their hands. All the solutions were excellent and it was definitely not an easy task for the jury to pick only one winner. Our solution, however, ended up being the winning one, despite the hard questions it got from the jury. The jury actually thought that our solution was so simple that it irritated them greatly that they had not thought about it themselves.</p>
<h2 id="conclusion">Conclusion</h2>
<p>We had a blast in this hack and based on our conversation with Luova Aalto, so did everybody else. Unfortunately, we weren’t able to witness what the teams had created in other challenges, for Jenkki and Happy Joe, but based on the atmosphere of the finals teams had a lot of fun working on those solutions.</p>
<p>At first, we were doubtful whether such a short hackathon could work in innovating a completely new business model for an existing service. But, this hackathon proved to be wildly successful in doing exactly that. This event serves as further proof for our view that a multidisciplinary team, design thinking, and constant questioning can solve basically any problem.</p>
<p>I will conclude this Hack Report by saying that Kuohu Creative was one of the friendliest yet one of the most effective hackathons we have seen in a long time. Not to mention the level of production and attention to detail in the participant experience, which were truly stellar. I’ve personally witnessed only one hackathon having this high level of production before, and they had a god damn film crew on the venue.</p>
<img alt="Hackathon Diaries: Kuohu Creative hackathon winners" loading="lazy" width="2048" height="1166" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fwinners.d7c205a9.png&amp;w=2048&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fwinners.d7c205a9.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fwinners.d7c205a9.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Hackathon Diaries: Oulu 5GFWD hackathon]]></title>
            <link>https://perttu.dev/articles/hackathon-diaries-oulu-5gfwd-hackathon</link>
            <guid isPermaLink="false">https://perttu.dev/articles/hackathon-diaries-oulu-5gfwd-hackathon</guid>
            <pubDate>Mon, 26 Jun 2017 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h3 id="virtual-classrooms-hospital-experiences-and-factories-of-tomorrow">Virtual classrooms, hospital experiences, and factories of tomorrow</h3>
<p><em>How to hack with 5G networks in mind? How do you manage three teams and ten hackers in an efficient manner? Is Oulu one of the best places to hold a hackathon?</em></p>
<h3 id="the-role-of-technology">The role of technology</h3>
<p>When 3G networks came, they brought us our first real chance in mobile internet and mobile video calls, among other things. Now it’s time to add another odd number in front of the letter G and, like before, supercharge the speed of the network. But what kind of world-changing solutions will this new 5G network bring? It seems that no one knows yet, but there’s a lot of potential!</p>
<p>Network technologies aren’t really my jam, but I see 5G networks — like any other cutting-edge technology — as an enabler. This hackathon provided a new kind of challenge that we were eager to take on. A well-defined problem and a polished solution wouldn’t cut it this time — we would also need to take into consideration what role this specific technology plays in all three solutions.</p>
<p>How did we do? Short answer: 1 win and 2 honorary mentions.</p>
<h3 id="the-challenges-we-took-up">The challenges we took up</h3>
<p>In total, there were three challenges, two from industry leaders Nokia and Telia, and one from the Oulu University Hospital. Here is a quick rundown of those challenges.</p>
<p><strong>Oulu University Hospital (OYS)</strong> challenged teams to improve their customers’ journey. With 42 kilometers of corridors, the hospital is a massive complex, and navigating through different departments takes ages. Additionally, all patients are given the same information in the same format, which is confusing to some patients. Since OYS is a special health care hospital, patients have no say when it comes to scheduling, leading to late or missed appointments, which is very inefficient.</p>
<p><strong>Nokia</strong> wanted participants to develop new ways of optimizing their already state-of-the-art telecom network equipment. To succeed, the hackers would need to make the current process even more digital than it currently is by utilizing machine learning, computer vision, wearable technology, augmented and virtual reality, and robotics. Nokia’s industrial environment challenges allowed our team to try building with novel technologies.</p>
<p><strong>Telia</strong> presented a challenge with the largest scope — mobile apps for the 5G age. Unlike Nokia’s and OYS’s challenges, there was no apparent real-world problem. This meant that to create a truly useful solution, our team would need to find a target group whose needs are not yet met by any other solution. We eventually selected students and educators as our target group and included a teacher student as one of our hackers to gain valuable insights.</p>
<hr>
<h3 id="how-we-worked">How we worked</h3>
<p>Perfektio set out to solve all three challenges with three separate teams. Our participant size was ten hackers, eight of whom were Perfektio employees and two guest additions (one a teacher!). Even with the guest additions, our established hackathon workflow proved effective, enabling us to leverage collective expertise into amazing concepts.</p>
<p>We don’t make strict rules on which hacker works in which team but instead opt to keep the teams and their members in a constantly fluctuating state and keep everyone updated on what is happening in each team. This allows us to allocate hackers based on every team’s need at the moment. In Oulu, this worked fantastically. For example, in the case of OYS, our primary team consisted of only two hackers but leveraged the collective input.</p>
<p>Before the hackathon, we keep the concepts fairly undefined because we don’t want to go into hackathons with ideas we can’t part ways with. This has been our approach for a long time. For the same reason, we start the first day of the hackathon by bombarding company representatives with questions. We then combine this with the knowledge of potential users we have gathered before or during the hackathon. Why? Because you can only solve problems if you understand them.</p>
<h3 id="the-teams-and-their-solutions">The teams and their solutions</h3>
<p><strong>Perfekt Patient</strong> set out to create the best customer journey ever seen in a hospital. A week before the hackathon, we reached out to doctors and people who had spent long times in hospitals. From these interviews and conversations with OYS representatives, we defined our problem space, focusing on guiding patients to the right place at the right time. Our solution was a mobile application coded with React Native. OYS appreciated our solution and awarded us an honorable mention.</p>
<p><strong>Industry Perfekt</strong> made an impressive demo and development plan for Nokia to optimize their component tracking process. With wearable technology and always-on optical reading, the tracking process would become much less prone to errors. Although we didn’t win the main prize, we received an honorary mention, which is always nice.</p>
<p><strong>Team Classroom</strong> created a dashboard for teachers that provided insights into students' engagement and included a VR demo of a heart for students. This solution enabled students to immerse more in teaching while giving teachers valuable insights into student learning. Telia loved our demo and awarded us the 10K main prize for it!</p>
<p>Oulu 5G hackathon was a truly positive experience. The venue and event were masterfully organized, <a href="https://www.youtube.com/watch?v=hTOR35WTpd8">and just look at how much fun we had</a>! Big thanks to Nokia, Telia, and OYS for the challenging projects, which were some of the toughest in our hackathon history — and that’s a good thing!</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Hackathon Diaries: Royal Hackaway]]></title>
            <link>https://perttu.dev/articles/hackathon-diaries-royal-hackaway</link>
            <guid isPermaLink="false">https://perttu.dev/articles/hackathon-diaries-royal-hackaway</guid>
            <pubDate>Wed, 24 Jan 2018 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h4 id="first-part-in-our-series-of-trips-to-hackathons-around-theworld">First part in our series of trips to hackathons around the&nbsp;world</h4>
<p><strong>What is the difference between a hackathon in Finland and one in UK?</strong> I wanted to find out how hackathons around the world differ and decided that this year I’m going to travel to different hackathons around the world. About a week ago I did my first little research trip. to Royal Hackaway, Royal Holloway‘s first student hackathon. Here’s what I found out.</p>
<h3 id="saturday">Saturday</h3>
<p>Early Saturday morning I stuffed one day’s worth of clothes and a borrowed sleeping bag into my trolley bag, and headed to the Helsinki airport. The flight went fine, although I did get a nosebleed the moment the wheels touched the landing strip, and dirtied the only hoodie I had with me (having bloodstains on your hoodie in hackathon shows character). After I had landed and stuffed a sizable amount of paper cloth in my nose, I made my way to the bus. There I played a round of “spot the tourist” with the bus driver before jumping on board. At this point I had spent about five hours traveling to this hackathon.</p>
<p>Once I reached the campus of Royal Holloway (<em>”The most beautiful university in UK”</em>), I snapped a quick picture of the premises and composed a Facebook post (I got like 20 likes, baller yeah). After that I made my way to the building where the hackathon would take place, received my badge and lunch vouchers, and walked into the auditorium where the show was about to start.</p>
<p>Theme of the hackathon turned out to be open data, a relatively original theme⸮ Once the welcoming speeches ended, we moved to another room and started hacking. No team formation, no nothing. This is a thing I found a little stupid because it felt that organizers did not consider that there could be people running solo (like me for example). I was able to force myself into one team after bragging how many hackathons I’ve won (14, not that I’m counting or anything).</p>
<p>Our team spent around two hours getting to know each other and coming up with an idea to build. We finally decided to build <strong>a tool that would put all the hackathons around you on a map, show the prizes, and provide fare estimates for flights</strong> (hey, <a href="https://medium.com/u/173a584048a">Major League Hacking</a>, the map part would be good addition to your site as well). After this it was time to decide who would work on what and what kind of stack we would use — we chose Vue.js, which was positive surprise.</p>
<blockquote>
<p>“…no offense but I thought you were&nbsp;shit!”</p>
</blockquote>
<p>What was my part in team? I started by reading a little Vue.js documentation in order to start building the frontend. After a rough start I was asked to build a HTML mockup instead. This I did for maybe 20 minutes, before I once again asked to do something else, this a photoshop mockup. Reason for constant pivots was my team’s distrust in achieving in these tasks; or at least achieving them in a way that would helps us win this thing.</p>
<p>Once I was done with the mockup I showed it to my team mates, half expecting to get disappointed looks. To my surprise, I got a heart warming <em>“dude this is really cool; no offense but I though you were shit!”</em> compliment on my mockup. After that our work together went really smoothly and we managed to build most of the app during Saturday. Sunday we spent fixing and rewriting everything we had written the day before.</p>
<h3 id="sunday">Sunday</h3>
<p>I got a pretty solid 4 hour sleep during the hackathon which you might consider to be a lot, but you are wrong. Well, first because little sleep can do wonders to your cognitive functions. In hackathon context you should aim around 50% of the sleep you get on an average night, to continue functioning at about average performance. Second reason is, because I’m old and weary I need my sleep (26, which was about 6 years over the average age of this hackathon).</p>
<p>Submission deadline was at 12, after which it was time for presentations. Judging by the presentations most teams seemed to consists of first timers, which is always cool. Hackathons need fresh blood.</p>
<p>When it was our time present, we got to stage and presented our project. We had actually created a two solutions: an API that gave us hackathons and webservice to used that API. We had to remove the fare estimates for flights because it turned out Google’s API and Skyscanner’s API aren’t open anymore. <a href="https://devpost.com/software/hackatrack-acdov4">You can find a more detailed explanation of the project here.</a></p>
<p>Then came time for the prizes. I’m quite sure that the judges decided to give prices to other teams instead of giving all of them to us, to keep up the morale. After all, our hackathon submission was a masterpiece. <a href="https://devpost.com/software/alpaca-074ags">The first place went to a solution that converts video and audio files to text summaries, with the help of ML, making it easier to study.</a> I’m a bit sad that my personal favorite, a web site which tells you if Trump thinks your country is a shithole, did not win. It had all the potential to be a disruptive tool in international politics.</p>
<h3 id="conclusion">Conclusion</h3>
<p>I want to say special thanks to the organizers of Royal Hackaway. I enjoyed the event a lot, especially the atmosphere. But I do also have small suggestions that could make the event event better. First, some kind of a team formation activity would make it easier to get started. Second, a more defined theme, or a challenge to solve that you advertise before the hackathon, could also make the hackathon more marketable.</p>
<p>I want to also thank my teammates, working with you during the weekend was a blast! I was also really impressed with your skills. I hope to see you in Start Hacks in Switzerland.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Hackathon Diaries: Talking plants with Fiskars]]></title>
            <link>https://perttu.dev/articles/hackathon-diaries-talking-plants-with-fiskars</link>
            <guid isPermaLink="false">https://perttu.dev/articles/hackathon-diaries-talking-plants-with-fiskars</guid>
            <pubDate>Tue, 19 Sep 2017 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h4 id="circular-economy-and-the-future-of-gardening">Circular economy and the future of gardening</h4>
<h2 id="premise">Premise</h2>
<blockquote>
<p><em>“Has anyone ever done any gardening?”</em></p>
</blockquote>
<p>The question echoed in our office for a short time, but was soon met by a snarky counter question wondering whether signing up for a university’s campus farming would suffice as gardening. Our office, a tiny 30 sqm room with a turquoise floor mat, located in a buzzing co-working space, was packed with eight people. The clock was already half past eight in the evening, and we were no closer to coming up with ideas that would make gardening more interesting and get us into this hackathon.</p>
<p>Room full of people and no one had ever even tried gardening. How were we ever going to get through the selection process of this hackathon, that had everything to do with gardening? What kind of a record breaking concept could a bunch of never-even-held-a-trowel-people create that would both bring gardening to the 21st century and guarantee a pilot project to develop further? You could say that the odds were not in our favor.</p>
<p>Did I mention that this Fiskars hackathon was organized by Industryhack, one of the best hackathon organizers, which also has the toughest application process? But why is it so tough? First of all this is the big league and there’s no hand holding. Most of the teams come from existing companies, some even from quite big companies such as Tieto and Kone. You cannot apply as an individual but instead you form a team of 2–3 people. Such a small team leaves no room for freeloaders or newbies.</p>
<p>Second of all, the submission, in which you first introduce yourself, your team members, your motivation and your idea is the only thing that affects whether you’re selected or not. We often spend more time polishing our submission to perfection than coming up with the idea. After all only 6–8 teams are selected to the hackathon from possibly dozens of teams. Getting selected into the hackathon is a already prize by itself — your idea and team sounded more promising than so many others.</p>
<p>We had to get into this hackathon. It had everything a young company needs, a potential for a pilot project and a big client. So we decided to submit two hackathon project proposals. Double the changes right? Quantity over over quality? Or perhaps a sure way to double the depression that you get from not getting selected at all.</p>
<h3 id="the-problem">The problem</h3>
<blockquote>
<p>”What makes gardening cool?!”</p>
</blockquote>
<p>So was any of our proposals selected for the hackathon? Drumroll please…. and the selected projects are…. project 1,project 2….project 7 and Shed (that’s us)! So one, out of the two ideas. Now let me describe both of the ideas and you try to guess which one got selected:</p>
<blockquote>
<p>“ Idea 1. We want to help gardeners to use the correct tools and get the correct information needed in order to succeed based on their gardening goal. With our mobile app you are taken from novice gardener to a&nbsp;pro”</p>
</blockquote>
<blockquote>
<p>“Idea 2. Our idea is a completely new business model for Fiskars where a combination of physical tool shed and mobile companion app allows people to rent Fiskars tools. With our service people can always find the correct tools from a place closest to&nbsp;them.“</p>
</blockquote>
<p>Now, which one would likely be the one to get selected? If you think that number two sounds lot more interesting, I don’t blame you. Why number one sounds quite dull, is partly because although it solves some real problems, there is not really an original thought behind it and no original problem.</p>
<p>What is “an original problem”? Isn’t the main thing in hackathons to provide solutions? It is true that quite often hackathons exist in vacuum where problems are made to fit the solutions the teams have already come up with before the hackathon. <a href="https://www.cooper.com/journal/2015/2/stop-solutionizing-and-start-problem-solving">There is a word for this, as defined by Cooper: solutionizing</a>. The only way to steer away from solutionizing is to solve original problems, which are problems that you did not invent. Our first idea is classical case of an invented problem — knowing what tools to use is not that severe case. Number two is quite vague, but goes in the right direction. Getting the right tools when needed is problem you might face several times a day in many different domains. How do you find these original problems? You do your research on the subject of course!</p>
<h3 id="the-team">The team</h3>
<blockquote>
<p>“Who the hell do these guys think they are?”</p>
</blockquote>
<p>Sugar. Spice. And everything nice. Accidentally add some Chemical X and you have the Powerpuff girls. But what do you have to mix to get the perfect hackathon team — I kept wondering while sitting in an airplane somewhere over the English channel. I was making my way back to Helsinki from UX Scotland conference where I had had a chance to witness Jared Spool talk about about integration of UX design into the whole organization. Many of the things mentioned there resonated with my thoughts on the perfect hackathon team. What does our Fiskars hack team have in common with Spool ideas?</p>
<p>So our Shed-team, consisted of three experts. Me a UX designer, Jyri a full-stack developer with expertise in developing 3D stuff, and Tuomas our very own business analyst (We also had Jonne, a designer/developer as my substitute on the first day). This is quite a typical hackathon team composition based on our experience. It also seems to work pretty well, both in our case and in cases where we have witnessed other teams winning.</p>
<p>We see that what matters the most is the overall experience of the whole team. Besides this it is also about how your team can work together despite lacking expertise and resources in some of the areas that are central for your hackathon concept. Designing for experiences is founded on unified expertise, both on hackathon level as well as on organisational level.</p>
<p>How do you achieve this? First no silos of experience and no pecking order. Jyri’s opinion about bad use flow, isn’t in anyway less worthy than mine and our team doesn’t cease working if I’m unable to participate on the first day of the hackathon (which actually happened, because of me being in Scotland). Hackathon team has no leaders. It is single organism that lives only to solve problems.</p>
<h3 id="solution">Solution</h3>
<blockquote>
<p>“That ain’t gonna win”, said one coder to another</p>
</blockquote>
<p>On Friday, around two o’clock Tuomas was getting ready to pitch the idea to Fiskars representatives. This pitch, however was a little different from what we’ve done before. It was done in a small room instead of a large stage. Instead of having three minutes to the sell the idea, we had 10. This gave us all the changes to sell an idea that would often be considered too large for hackathon concept.</p>
<p>Ideas ain’t worth a dime however. So instead of simply selling big ideas you also have to sell your expertise and the factors that make you better than all the other teams. We ended doing pretty well in this, as we were one of the three teams Fiskars decided continue the discussion with.</p>
<p>I can’t really tell you about the solution itself, as the discussion concerning it still ongoing. I can tell you that we did a mobile prototype and that there was a circular economy aspect to it. Our concept was very well received by Fiskars, but did not do so well when other teams we asked to vote for their favorite concepts. Mostly this was because our concept wasn’t so technically inspiring. Oh well, you cant’ have everything.</p>
<h3 id="conclusion">Conclusion</h3>
<p>So did we get the pilot project? That is yet to be decided. In the last meeting we had with Fiskars things were looking quite good. Hopefully in the future you will see our service in use, as we think it is pretty frickin cool’. However, once the discussion concludes we will tell you more about the service itself.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Help test Netli.fyi, a Netlify client for iOS (and soon Android)]]></title>
            <link>https://perttu.dev/articles/help-test-netlifyi-netlify-client-for-ios</link>
            <guid isPermaLink="false">https://perttu.dev/articles/help-test-netlifyi-netlify-client-for-ios</guid>
            <pubDate>Wed, 26 Nov 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>I've been building a small project that I've wanted on my own phone for a long time: Netli.fyi, a native iOS client for Netlify. It gives you quick access to your sites, build logs, domains, and account info without needing to open a laptop. It's now at a point where it needs real world testing, which is where you come in.</p>
<p>If you use Netlify even a little, I'd love your help trying the beta. In case you’re inpatient, here’s the <a href="https://testflight.apple.com/join/XxMMHHQ2">link to the TestFlight</a>.</p>
<h2 id="didnt-you-build-this-app-already-once-before">Didn’t you build this app already once before?</h2>
<p>Yes, I did. You can find the original announcement <a href="https://dev.to/plahteenlahti/i-built-a-react-native-app-to-manage-netlify-hosted-sites-192c">here</a>. I eventually had to pull the app from the app stores because updating it was eating up my time (the app was mostly free and I had to focus on my actual job). During the years it’s been out of the app stores, some users have been emailing me with bug reports and feature requests, so I decided to finally answer all of those users and bring the app back.</p>
<h2 id="what-netlifyi-does">What Netli.fyi does</h2>
<p>Netli.fyi's goal is simple: take the parts of Netlify you check often and make them fast and comfortable on iOS.</p>
<h3 id="manage-sites">Manage sites</h3>
<p>You can browse all your sites at a glance with deployment status, frameworks, and thumbnails. There's a full editor for environment variables, plus access to build settings like build command and publish directory.</p>
<h3 id="build-and-deploy-tools">Build and deploy tools</h3>
<p>You can trigger builds right from your phone and watch deploy logs update almost in real time. Deploy history is there too, so you can spot failures quickly.</p>
<h3 id="domain-and-dns-access">Domain and DNS access</h3>
<p>You can view your DNS zones, inspect individual records, and copy name servers or domain info with one tap.</p>
<h3 id="account-support">Account support</h3>
<p>The app supports multiple teams and profiles. You can switch between them easily, check your stats, and pick light or dark mode based on your system settings.</p>
<h2 id="under-the-hood">Under the Hood</h2>
<p>For anyone curious about the tech, the app is built with a modern React Native stack: Expo with the new architecture, Expo Router for navigation, Jotai for state, TanStack Query for data, and NativeWind for styling.</p>
<p>In short, it aims to feel fast.</p>
<h2 id="want-to-join-the-beta">Want to Join the Beta?</h2>
<p><a href="https://testflight.apple.com/join/XxMMHHQ2">Here’s a TestFlight link</a>, I’ll try to add the Android version soon as well.</p>
<p>Before launching the first public version, I want to be sure the app feels right in actual day to day use. I'm mainly looking for feedback on:</p>
<ul>
<li>Performance. Does the app feel quick on your phone?</li>
<li>Navigation. Are the screens and flows clear?</li>
<li>Bugs. Crashes, odd UI behavior, broken states.</li>
<li>Missing features. What would you expect from a mobile Netlify client?</li>
</ul>
<p>Thanks for taking the time to read this and for helping me shape the app.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Designing for healthier life]]></title>
            <link>https://perttu.dev/articles/how-to-design-for-healthier-life</link>
            <guid isPermaLink="false">https://perttu.dev/articles/how-to-design-for-healthier-life</guid>
            <pubDate>Mon, 01 Mar 2021 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Four approaches to designing digital services that persuade your user to live a healthier life</p>
<p>Changing your habits from unhealthy to healthy ones is hard.</p>
<p>Fortunately changing behavior has become easier with technology. We don’t necessarily need that expensive coach to push towards our goal anymore, and a cheap mobile app might even suffice. The question is, how can we build services that help users to change their behavior?</p>
<p>Change in behavior cannot be forced, but we can persuade the user to change his behavior&nbsp;through <em>Persuasive Design</em>.</p>
<h3 id="why-do-you-need-to-understand-persuasive-design">Why do you need to understand Persuasive Design?</h3>
<p>Apps that enable users to order food are kindergarten level in terms of design complexity to apps that try to change the user’s eating habits. Designing products that change behaviors and attitudes is complicated because it forces us to think beyond what the user <strong><em>wants</em></strong> to do, and instead focus on how to get them to do things they <strong><em>should</em></strong> do.</p>
<p>You don’t need to be designing a product for healthier living to benefit from understanding the principles of persuasive design. The basic thesis behind persuasive design, changing user’s behavior to targeted one, is relevant in any domain.</p>
<h3 id="four-approaches-to-persuasive-design">Four approaches to Persuasive design</h3>
<p>There are four design approaches for persuading the user to change his behavior: <strong>primary task support,</strong> <strong>dialogue support,</strong> <strong>social support,</strong> and <strong>creditability support.</strong> Using the different methods and principles in combination maximizes persuasiveness of the service we are designing. A higher level of persuasion means the user is more probable to change his behavior.</p>
<h4 id="primary-task-support--making-everything-easier">Primary task support – making everything easier</h4>
<p>The main function of a user interface is to support the user in carrying out the primary task they have at the moment. For a user that aims to exercise regularly, this means, for example, turning the more significant job of exercising into smaller ones, such a picking a suggested training program. This is called the reducing principle.</p>
<p>Seven principles make up the primary task support approach:</p>
<ol>
<li><strong>Reducing</strong>: chunking complex task into smaller ones, e.g. reducing the effort of going for a run by suggesting a running route.</li>
<li><strong>Tunneling</strong>: guiding the user through a process, e.g. providing a step by step on how to produce a healthy meal.</li>
<li><strong>Tailoring</strong>: providing context-specific information, e.g. showing different information for beginners and professionals.</li>
<li><strong>Personalizing</strong>: providing personalized information.</li>
<li><strong>Self-monitoring:</strong> tracking the user's performance in the task, e.g. providing step count in an activity app.</li>
<li><strong>Simulating:</strong> simulating the link between cause and effect, e.g. showing before-and-after pictures of people with the same weight.</li>
<li><strong>Rehearsal</strong>: practicing targeted behavior, e.g. using an app to train meditation for example.</li>
</ol>
<h4 id="dialogue-support--providing-goodfeedback">Dialogue support – providing good&nbsp;feedback</h4>
<p>Users expect some form of feedback when using an app or a service to achieve a goal or change in behavior. In persuasive design, this should be seen as dialogue, in which users actions are reflected in the system that allows for a continuation of the interaction. For example, when the user accomplished something they set out to do, the service acknowledges this and provides next steps for after it. To provide meaningful feedback, your service should incorporate the following principles:</p>
<ol>
<li><strong>Praising</strong>: offering positive feedback, e.g. congratulating the users for achieving her goals.</li>
<li><strong>Targeting</strong>: rewarding the user for targeted behavior, e.g. giving out trophies.</li>
<li><strong>Reminding</strong>: reminding the user of their targeted behavior, e.g. notifications about today’s running goal.</li>
<li><strong>Suggesting</strong>: suggesting fitting ways to achieve targeted behavior, e.g. food app suggesting healthier alternatives.</li>
<li><strong>Similarity</strong>: proving the service in a way that is familiar for the user, e.g. using users language, slang, etc.</li>
<li><strong>Liking</strong>: providing a look and feel that appeals to the user, e.g. cartoony graphics for an app that is meant to teach children to brush their teeth.</li>
<li><strong>Social role</strong>: offering a social connection for the user, e.g. connecting users to professional coaches.</li>
</ol>
<h4 id="social-support--using-humans-socialbehavior">Social support – using human’s social&nbsp;behavior</h4>
<p>We are social beings. Doing things in a group and competing against each other can, in many cases, lead to better results. Services that aim to change behavior have the added benefit of social pressure if done right. Being social of the things we are changing in ourselves pushes us to follow through. Ways of building social support in your service are the following:</p>
<ol>
<li><strong>Social learning:</strong> a person is more prone to change her own behavior if given a chance to observe others, e.g. allowing users to share their completed workouts.</li>
<li><strong>Social comparison</strong>: allowing people to compare their performance to others, e.g. people can compare their pace for running 10k.</li>
<li><strong>Normative influence</strong>: using peer pressure, e.g. allowing a group of people to set a shared goal that everyone must work to achieve.</li>
<li><strong>Social facilitation</strong>: people are more likely to perform target behavior if they feel other people are doing it as well.</li>
<li><strong>Cooperation</strong>: using the natural drive to co-operate as leverage, e.g. allowing a group to set for example a shared weight loss goal.</li>
<li><strong>Competition</strong>: using people’s drive to compete, e.g. win a prize if you run more miles than your peers.</li>
<li><strong>Recognition</strong>: offering public recognition, e.g. interviews of the people who achieved their goal with the service or showing a global leaderboard for most runs.</li>
</ol>
<h4 id="credibility-support--be-trustworthy">Credibility support — be trustworthy</h4>
<p>Besides providing the means, the feedback, and the social elements for changing behavior, your service should also be credible. More credibility converts to more persuasiveness. There are seven aspects that increase the creditability of your service:</p>
<ol>
<li><strong>Trustworthiness:</strong> users are more likely to follow instructions of a trustworthy app.</li>
<li><strong>Expertise:</strong> services that are perceived to be based on expertise and researched knowledge are more persuasive.</li>
<li><strong>Surface creditability</strong>: people make a quick judgment of the system's creditability based on several factors. Using a cartoony font, for example, might reduce the professional aspect of the service.</li>
<li><strong>Real-world feel</strong>: providing information on the people behind the service builds creditability.</li>
<li><strong>Authority</strong>: using authority to build more creditability and increase persuasiveness.</li>
<li><strong>Third-party endorsements</strong>: well-known and respected sources as endorsers work wonders in building creditability.</li>
<li><strong>Verifiability</strong>: allowing the user to verify services claims, for example, by linking to outside sources.</li>
</ol>
<h3 id="conclusion">Conclusion</h3>
<p>The presented approaches and principles are some of the most fundamental ideas behind the persuasive design. However, despite multiple the ways that we can increase the persuasiveness of the services and products that we design, changing user’s behavior is a rather difficult task. We can also see this in the surprisingly few products that are in the market today, which are good at changing their user’s behavior. This, of course, should not be seen as discouragement, but rather as an incentive to work even harder at building these products that change our behaviors for the better. There is a huge market for the products which help us realize our potential and live a healthier and happier life.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[How to get the old Slack UI back]]></title>
            <link>https://perttu.dev/articles/how-to-get-the-old-slack-ui-back</link>
            <guid isPermaLink="false">https://perttu.dev/articles/how-to-get-the-old-slack-ui-back</guid>
            <pubDate>Fri, 08 Dec 2023 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>A bit over a month ago, Slack rolled out a new updated UI. Safe to say that this update is a mess. Things that were previously always visible and easy to find, such as your other Slack workspaces and combined channels/direct messages list are now hidden behind extraneous clicks. If I were a UX/UI designer at Slack, I would be embarrassed to mention where I work at this point.</p>
<p>Thankfully, there is a way to revert to the old UI, as discovered by the Reddit user <a href="https://www.reddit.com/r/Slack/comments/16ib0l7/psa_you_can_revert_your_slack/">u/guitwo</a>. Their original instructions are for the browser client, but I much prefer the desktop client. Following these instructions, you should be able to get the old UI back on the Mac desktop client of Slack:</p>
<h2 id="enabling-the-developer-menu">Enabling the developer menu</h2>
<p>We first need to enable the developer menu in order to run a small piece of JavaScript code. Start by first closing your Slack client. After that, run the following command to enable the developer menu for the client:</p>
<pre class="language-bash"><code class="language-bash"><span class="token builtin class-name">export</span> <span class="token assign-left variable">SLACK_DEVELOPER_MENU</span><span class="token operator">=</span>true
</code></pre>
<p>Because the Slack desktop client is just an Electron app, i.e., a web app pretending to be a real application, "the developer menu" is actually just Chrome's Developer Tools.</p>
<p>After running the command, reopen Slack by running the command:</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">open</span> /Applications/Slack.app
</code></pre>
<h2 id="2-switching-the-configuration-to-use-the-old-ui">2. Switching the configuration to use the old UI</h2>
<p>Next, we need to run a small bit of JavaScript in Slack's developer menu. Use the hotkey <kbd class="font-regular rounded-lg border border-gray-200 bg-gray-100 px-1.5 py-1 text-xs text-gray-800 dark:border-gray-500 dark:bg-gray-600 dark:text-gray-50">Cmd ⌘</kbd> + <kbd class="font-regular rounded-lg border border-gray-200 bg-gray-100 px-1.5 py-1 text-xs text-gray-800 dark:border-gray-500 dark:bg-gray-600 dark:text-gray-50">Option ⎇</kbd> + <kbd class="font-regular rounded-lg border border-gray-200 bg-gray-100 px-1.5 py-1 text-xs text-gray-800 dark:border-gray-500 dark:bg-gray-600 dark:text-gray-50">I</kbd>.</p>
<p>If nothing shows up, repeat the steps from before. Once you have the developer menu open, run the following commands:</p>
<pre class="language-javascript"><code class="language-javascript"><span class="token comment">// get the current local configuration object</span>
<span class="token keyword">const</span> localConfig <span class="token operator">=</span> <span class="token dom variable">localStorage</span><span class="token punctuation">.</span><span class="token method function property-access">getItem</span><span class="token punctuation">(</span><span class="token string">'localConfig_v2'</span><span class="token punctuation">)</span>
<span class="token comment">// toggle off the is_unified_user_client_enabled</span>
<span class="token keyword">const</span> updatedConfig <span class="token operator">=</span> localConfig<span class="token punctuation">.</span><span class="token method function property-access">replace</span><span class="token punctuation">(</span>
  <span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">\"is_unified_user_client_enabled\":true</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span>
  <span class="token string">'"is_unified_user_client_enabled":false'</span><span class="token punctuation">,</span>
<span class="token punctuation">)</span>
<span class="token comment">// set the item to local storage</span>
<span class="token dom variable">localStorage</span><span class="token punctuation">.</span><span class="token method function property-access">setItem</span><span class="token punctuation">(</span><span class="token string">'localConfig_v2'</span><span class="token punctuation">,</span> updatedConfig<span class="token punctuation">)</span>
</code></pre>
<p>Now close Slack one more time and reopen it. You should see the old Slack UI again!</p>
<p>There are certain caveats with this approach. One, the app will revert back to the new UI once you fully close the Slack app. Second, there are no guarantees how long this will work.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[How to make money with your Android app]]></title>
            <link>https://perttu.dev/articles/how-to-make-money-with-your-android-app</link>
            <guid isPermaLink="false">https://perttu.dev/articles/how-to-make-money-with-your-android-app</guid>
            <pubDate>Tue, 02 Dec 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Building a great Android app takes time, creativity, and persistence. But once your app is live, there’s one question every developer eventually faces: <strong>how do I actually make money from this?</strong></p>
<p>Android developers often struggle with monetization more than iOS developers. Not only are <a href="https://wezom.com/blog/iphone-vs-android-users-key-differences-in-2025">Android users statistically less likely to pay for apps</a>, but implementing in-app purchases or subscriptions comes with technical and operational hurdles that can overwhelm solo devs or small teams.</p>
<p>This guide breaks down <strong>every major Android monetization model that brings in recurring revenue</strong>, explains why some apps succeed while others fail, and walks through practical implementation strategies using RevenueCat.</p>
<div class="my-6 rounded-lg border p-2 border-blue-200 bg-blue-50/50 dark:border-blue-800/50 dark:bg-blue-900/20"><div class="flex gap-2"><div class="shrink-0 text-blue-600 dark:text-blue-400"><svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"></path></svg></div><div class="flex-1 min-w-0"><div class="prose-xs prose-zinc dark:prose-invert max-w-none text-xs my-0"><p>I work for RevenueCat and this article has a section outlining why you should use RevenueCat to handle in-app purchases. You can of course implement monetization in your app in multiple ways. However if you decide to go with RevenueCat, and need help setting up, don't be afraid to get in touch.</p></div></div></div></div>
<h2 id="im-too-lazy-to-read-show-me-the-video">I'm too lazy to read, show me the video</h2>
<p>I got ya, here is the video version of this article from Droidcon London 2025 and Droidcon Berlin 2025:</p>
<div class="my-6 grid gap-4 grid-cols-1 md:grid-cols-2"><div class="relative w-full aspect-video rounded-lg overflow-hidden bg-zinc-900"><iframe src="https://www.youtube.com/embed/RG1qMNt9vuo" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen="" class="absolute inset-0 w-full h-full"></iframe></div><div class="relative w-full aspect-video rounded-lg overflow-hidden bg-zinc-900"><iframe src="https://www.youtube.com/embed/fNsIYaR8jcE" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen="" class="absolute inset-0 w-full h-full"></iframe></div></div>
<h2 id="why-monetizing-an-app-is-hard-especially-when-its-on-android">Why monetizing an app is hard, especially when it’s on android</h2>
<p>There are three main challenges developers face when trying to earn money from Android apps.</p>
<h3 id="1-users-expect-free-apps">1. Users expect free apps</h3>
<p>Unlike iOS users, Android users have been conditioned to expect free experiences. Many come from markets where disposable income is lower, and a large portion of the Android ecosystem runs on ad-supported or open-source apps.</p>
<p>Looking at top selling Android phones, which are usually priced in a few hundreds dollars, it’s no wonder most users expect apps in their phones for free. When compared, a 50 dollar yearly subscription feels way more expensive on a Samsung A16 costing 150 dollars, than on the cheapest iPhone (16E) priced at 600 dollars.</p>
<p>Possibly for this reason developers on Android often see one-star reviews the moment they move previously free features behind a paywall. It’s more than likely that these reviews come from users who genuinely like the app, but resent being asked to pay. Winning over these users requires convincing users your app is worth supporting.</p>
<h3 id="2-implementation-complexity">2. Implementation complexity</h3>
<p>Monetizing users is hard, but adding monetization to your app isn’t easy either. Even adding in-app purchases or subscriptions isn’t a plug-and-play process, often requiring:</p>
<ul>
<li>A backend service for validating receipts.</li>
<li>Logic for managing active and expired subscriptions.</li>
<li>Handling user authentication and cross-device restoration.</li>
<li>Keeping up with Google Play Billing API changes.</li>
<li>Navigating regulatory requirements like EU subscription rules.</li>
</ul>
<p>It is not uncommon to come across large teams in big companies, whose sole purpose is to develop and maintain the in-app purchase infrastructure of the company’s Android app. Google Play Billing provides a lot of functionality out of the box, but you still need to build a lot around that API to provide a great purchasing experience.</p>
<p>Even if you build it perfectly once, you’ll need to maintain it as well, which will likely be an even bigger time sink. Every API change or edge case can break your monetization flow and cost you real money. For indie developers, this kills a lot of momentum from delivering features, not to mention the way it eats up motivation, when you’re forced to build “boring stuff” (you have to be a real sicko to enjoy working with Google Play Billing API’s).</p>
<h3 id="3-android-apps-earn-less-on-average">3. Android apps earn less on average</h3>
<p>Combing through the data of RevenueCat’s State of Subscription Apps highlights the difficulties of monetizing Android apps through subscriptions. Even with similar user bases, Android apps tend to earn less than their iOS counterparts:</p>
<a href="https://www.revenuecat.com/state-of-subscription-apps-2025/" target="_blank"><img alt="Trial Start Rate, By Store chart from RevenueCat State of Subscription Apps 2025" loading="lazy" width="2626" height="1602" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fsosa.64474c0e.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fsosa.64474c0e.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX"></a>
<p><strong>Fewer people start trials on Google Play</strong> than on the App Store. Even at the high end, only about 14% of users start a trial on Google Play, compared to roughly 23% on the App Store. Revenue per install is also significantly lower on Android apps than iOS in general. However top performers on Android do see comparable revenue to iOS.</p>
<p>So even though monetizing Android apps is certainly possible, it is way way harder than on iOS. Boringly, the playbook for making money is the same on both platforms: building high quality apps, addressing a real need, and having the right monetization strategy. Let’s look at the last point in the next section.</p>
<h2 id="options-for-monetizing-on-android">Options for monetizing on Android</h2>
<p>Right off the bat: there is no single perfect monetization method. What works, when and how, depends on a variety of things. Successful apps often combine several different strategies. Here’s how to think about each approach.</p>
<h3 id="1-advertising-simple-but-scale-dependent">1. Advertising: simple but scale-dependent</h3>
<p>Everyone on Android knows ads. As an iOS person it feels like every Android app has ads in it. When I’ve done roundtables in Android conferences, it seems that more devs have ads in their apps than in-app purchases. Ads are the easiest way to start earning money. Integrate AdMob, AppLovin, or another ad network, and you’ll begin to see small amounts of revenue per user.</p>
<p>However, the problem with ads is the scale they require to turn any meaningful revenue. If you take the <a href="https://kk.org/thetechnium/1000-true-fans/">1,000 true fans hypothesis</a> and make those fans pay in the form of ad clicks, you’re making only between $1 and $10 every month.</p>
<p>If your app has a viral or casual audience — games, wallpapers, entertainment — ads can work well. For productivity or paid tools, they tend to feel intrusive.</p>
<p><strong>Pros:</strong></p>
<ul>
<li>Easy to implement.</li>
<li>Works for completely free apps.</li>
<li>Passive, recurring income.</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li>You need massive user volume to make meaningful money.</li>
<li>Ads can hurt the UX and damage your brand.</li>
<li>Doesn’t scale well for niche or professional apps.</li>
</ul>
<h3 id="2-one-time-purchases-simple-value-exchange">2. One-time purchases: simple value exchange</h3>
<p>Google Play Store supports two types of <strong>One-time products, consumables</strong> and <strong>non-consumables</strong>; former you can use for recurring revenue, latter for one time unlocks .</p>
<ul>
<li><strong>Consumables:</strong> Can be purchased multiple times (e.g., extra lives, credits, tokens, tips).</li>
<li><strong>Non-consumables:</strong> Unlock permanent features (e.g., dark mode, ad removal). \</li>
</ul>
<p>Non-consumable purchases don’t generate recurring income, and they cap your revenue at the number of users willing to pay once. The mental model of it is similar to that of the old boxed software, where you would release a new version of the app one a year, and people who needed the new features would upgrade. In the time of monthly server costs (few apps can do without a backend), this model just doesn’t work anymore.</p>
<p>Consumables can bring you the much needed recurring revenue, but work very poorly for unlocking features. The repeated buying of consumables makes them great for games, where purchasing allows users to continue playing, for example, by buying additional lives.</p>
<h3 id="3-subscriptions-the-gold-standard">3. Subscriptions: the gold standard</h3>
<p>Subscriptions power the most profitable apps in the world. They give you predictable, recurring income and create a healthy incentive loop: the more value you provide over time, the longer users stay. They do also come with downsides:</p>
<ul>
<li>Since everything is now a subscription, “subscription fatigue” can make customers lose interest</li>
<li>Dark patterns in getting users to subscribe to for example weekly subscriptions, can deter users from making a purchase if your app doesn’t have a good reason for paying for usage that often.</li>
</ul>
<p>It’s important to select the right type of subscription periods for your app. One way to think of these:</p>
<ul>
<li><strong>Weekly:</strong> Low commitment, popular in certain lifestyle categories.</li>
<li><strong>Monthly:</strong> Common baseline for SaaS-like apps.</li>
<li><strong>Yearly:</strong> Encourages long-term commitment, often with a discount.</li>
</ul>
<p>Health, fitness, productivity, and education apps do especially well here. Users perceive them as ongoing journeys — and are more willing to commit for a full year.</p>
<p>The key is <strong>delivering consistent value.</strong> A subscription app that stops improving or engaging users will quickly see churn.</p>
<h3 id="4-virtual-currencies-build-your-own-economy">4. Virtual Currencies: build your own economy</h3>
<p>This one has nothing to do with cryptos, virtual currencies are a flexible custom in-app economy. Instead of selling specific features, you sell credits that users can spend inside your app. There are other names for this as well such as credits, tokens, minutes.</p>
<p>Virtual currencies are easiest to explain through an example: if you build an AI-powered photo enhancer, you might sell 100 credits for $5, where each image generation costs 1 credit. You can even combine this with subscriptions: subscribers get monthly credits and can top up via consumable purchases.</p>
<p>This approach works beautifully for:</p>
<ul>
<li>AI/ML apps.</li>
<li>Games.</li>
<li>Creative tools with usage-based costs.</li>
</ul>
<p>I feel that there’s a huge unexplored potential with virtual currencies.</p>
<h3 id="hybrid-monetization-strategies">Hybrid monetization strategies</h3>
<p>The best way to go with monetizing is still to combine multiple strategies. Should be careful though, people can smell when you’re trying to just squeeze money out of them in multiple ways. Monetization should always make sense; people pay you because they feel they are getting the amount of value that they are paying for you or more. The more they end up paying, the more they expect to receive value in return. So make sure all your monetization has a purpose.</p>
<p><strong>1. Ads + Subscription</strong></p>
<p>Show ads to free users. When someone subscribes, remove the ads. If they cancel, ads return automatically once their entitlement expires. People subscribe for two reasons, at least technically:</p>
<ol>
<li>Get rid of the annoying ads</li>
<li>Because they actually like the product</li>
</ol>
<p><strong>2. Subscription + credits</strong></p>
<p>Give subscribers a monthly credit allowance (e.g., 100 exports). Heavy users can top up with one-time purchases. You capture value from both subscriptions, as well power users using your app more.</p>
<h2 id="building-your-subscriptions-infrastructure">Building your subscriptions infrastructure</h2>
<p>One word: don’t</p>
<h2 id="buying-your-subscription-infrastructure">Buying your subscription infrastructure</h2>
<p>Ok, maybe this counts as a bit of an ad, <em>but you should go with <a href="https://www.revenuecat.com/">RevenueCat</a></em> , and I’m certainly biased since I work there so take whatever I say with a modicum of salt.</p>
<p>There are very few rare cases where you should build your own subscription infrastructure, or even go with anything else. For starters Google Play Store APIs are generally not fun to work with: they are constantly changing, at times poorly documented, and well… built by Google 🤷‍♂️. Receipt validation, subscription status, and billing errors are just some of the things you will end up having to figure out. From personal experience I can say that there are way more fun things to do in app development.</p>
<p>There are other reasons for not building your subscription infrastructure yourself. The laws, rulings, and regulations around in-app purchases change constantly and you would have to keep up with all the markets your app is available in. Your development time is also limited, and I personally think developers should focus on bringing value to their users – not on the mechanics of extracting value from their users. \</p>
<p>Now all those horrible things I’ve told you about, RevenueCat takes care of them all. So give it a try, it’s  free until you make $2,500/month, then pay 1% of tracked revenue. If you have any questions, just reach out to me.</p>
<h2 id="adding-revenuecat-to-your-android-project">Adding RevenueCat to your Android project</h2>
<p>This article is already rather long and I still have a few points to consider, so I’m not going to add a tutorial here about adding RevenueCat to your Android app, but instead I’m going to link a few sources.</p>
<ol>
<li>The best source for getting started with RevenueCat is their docs. Specifically this <a href="https://www.revenuecat.com/docs/getting-started/installation/android">RevenueCat Android SDK setup</a>.</li>
<li><a href="https://revenuecat.github.io/">RevenueCat Codelab</a> gives you much of the same stuff but slightly more interactively.</li>
</ol>
<h2 id="keep-more-revenue-by-purchasing-on-web">Keep more revenue by purchasing on web</h2>
<p>So far we’ve only talked about in-app purchases, where Google handles the  underlying payment infrastructure. Google requires that all digital purchases made apps have to make use of their in-app infrastructure. For that honor they take a **15–30% **cut of all in-app purchase transactions. That’s fine early on, but when your app starts to grow, that cut will start to feel quite big.</p>
<p>However, it’s just a matter of time before purchasing through alternative payment providers becomes available for the Play Store. At least in the way it is for the App Store already in the US and soon in Japan.</p>
<p>So build your own web payment solution or use <a href="https://www.revenuecat.com/billing/">RevenueCat Web billing</a> to do that. Let users pay via Stripe or Paddle, where commissions are 3–4%, and then just synchronize the purchase with your app. To stay compliant with store rules:</p>
<ul>
<li>Don’t link directly to external payments in your app (unless allowed by law).</li>
<li>Use email or in-app messaging to direct users to your web checkout.</li>
<li>When they complete the purchase, deep-link back to your app and associate the transaction. \</li>
</ul>
<p>Easiest way is to use the web purchases for <strong>win-back campaigns</strong>. When a user cancels, automatically send them a discount link via email — e.g., “Come back and save 30%.” Since the transaction happens on the web, you don’t lose 30% to Google. If you’re interested in learning more, I’ve written a tutorial about <a href="https://www.revenuecat.com/blog/growth/how-to-build-a-win-back-campaign-with-revenuecat-web-billing/">web-based win-back campaigns with RevenueCat</a>.</p>
<h2 id="final-thoughts">Final thoughts</h2>
<p>Making money on Android isn’t easy, but it’s absolutely possible. Users are more comfortable with paying for apps they love to use nowadays, and in tricky cases you can always rely on the power of hybrid monetization models e.g. ads + subscriptions.</p>
<p>To emphasize the point I made earlier in this blog post: the best strategy for making money with your Android app is to build something people want and need. That means features and bug fixes, not wrestling in-app purchases infrastructure. After you've built something that people want, and monetized it in a way that makes sense, you can start looking into tweaking your monetization with web purchases for example.</p>
<hr>
<h2 id="related-articles">Related articles</h2>
<ul>
<li><a href="/articles/mobile-app-monetization-guide">How to Monetize Your Mobile App in 2026</a> - Complete guide covering iOS &amp; Android monetization strategies</li>
<li><a href="/articles/react-native-in-app-purchases-revenuecat">React Native In-App Purchases with RevenueCat</a> - Step-by-step implementation guide</li>
<li><a href="/articles/react-native-paywall-revenuecat-purchases-ui">Building a Subscription Paywall in React Native</a> - Pre-built paywalls with RevenueCat Purchases UI</li>
<li><a href="/articles/in-app-purchases-rn">React Native in-app purchases: iOS &amp; Android guide</a> - Comprehensive in-app purchases overview</li>
</ul>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[How to use App Store Connect (ASC) CLI]]></title>
            <link>https://perttu.dev/articles/how-to-use-App-Store-Connect-CLI</link>
            <guid isPermaLink="false">https://perttu.dev/articles/how-to-use-App-Store-Connect-CLI</guid>
            <pubDate>Wed, 04 Mar 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>If you've ever spent an afternoon clicking through App Store Connect to update metadata, upload a build, or — god forbid — localize your app listing into 15 languages, you know the pain. The web UI is slow, repetitive, and to be cumbersome for actual workflows. It almost doesn't feel like an Apple product. Using something like <a href="https://helm-app.com/">Helm, greatly improves the experience of working with App Store Connect</a>, but wouldn't it be nice to just ask agents to do these things?</p>
<p>That's where <a href="https://github.com/rudrankriyam/App-Store-Connect-CLI">ASC CLI</a> steps in. It's an unofficial, open-source command-line tool for App Store Connect APIs, that lets you manage your entire App Store workflow from the terminal. Builds, submissions, metadata, localizations, TestFlight — all of it. And when you pair it with AI agent skills, things get really interesting. But we'll get to that in a bit.</p>
<p>Let's set it up first.</p>
<h2 id="step-1-install-asc">Step 1: Install ASC</h2>
<p>One line, thanks to Homebrew:</p>
<pre class="language-bash"><code class="language-bash">brew <span class="token function">install</span> asc
</code></pre>
<p>ASC is in very active development and doesn't auto-update, so get in the habit of running this every now and then:</p>
<pre class="language-bash"><code class="language-bash">brew upgrade asc
</code></pre>
<h2 id="step-2-connect-to-app-store-connect">Step 2: Connect to App Store Connect</h2>
<p>You'll need an App Store Connect API key — the same kind you use for RevenueCat, Fastlane, or any other service that talks to the App Store Connect API.</p>
<h3 id="21-create-an-api-key">2.1 Create an API key</h3>
<p>Head to <a href="https://appstoreconnect.apple.com/access/integrations/api">Users and Access → Integrations → App Store Connect API</a> in App Store Connect and create a new key:</p>
<img alt="App Store Connect API integrations page showing the option to create a new API key" loading="lazy" width="2394" height="684" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fintegrations.db588c37.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fintegrations.db588c37.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>It'll ask you about access level. <strong>Admin</strong> gives you full access to everything. <strong>App Manager</strong> is enough if you're only managing app metadata and builds.</p>
<p>Once created, download the <code>.p8</code> key file immediately. Apple only lets you download it once — if you lose it, you'll need to generate a new one. I'd recommend dropping it straight into 1Password or your preferred secret manager.</p>
<p>You'll also need two values from this page:</p>
<ol>
<li><strong>Issuer ID</strong></li>
<li><strong>Key ID</strong></li>
</ol>
<p>Here's where to find them:</p>
<img alt="App Store Connect API page showing the Issuer ID and Key ID values" loading="lazy" width="1478" height="660" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fissuer.a3a2a90c.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fissuer.a3a2a90c.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fissuer.a3a2a90c.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<h3 id="22-authenticate-the-cli">2.2 Authenticate the CLI</h3>
<p>Now wire it all together. ASC can store credentials in a few ways — the simplest is a local config file:</p>
<pre class="language-bash"><code class="language-bash">asc auth login <span class="token punctuation">\</span>
  --bypass-keychain <span class="token punctuation">\</span>
  --name <span class="token string">"YourAppsName"</span> <span class="token punctuation">\</span>
  --key-id <span class="token string">"ABC123"</span> <span class="token punctuation">\</span>
  --issuer-id <span class="token string">"DEF456"</span> <span class="token punctuation">\</span>
  --private-key /path/to/AuthKey.p8
</code></pre>
<p>That's it. You're connected. But let's still break down what each part does. <code>asc auth login</code> tells the CLI you want to save a new set of credentials. <code>--bypass-keychain</code> stores them in a simple local file instead of the macOS Keychain — less hassle. <code>--name</code> is just a label so you can tell your credentials apart if you have multiple keys. <code>--key-id</code> and <code>--issuer-id</code> are the two values you copied from the App Store Connect API page. And <code>--private-key</code> points to the <code>.p8</code> file you downloaded. Once you run this, every future ASC command is automatically authenticated. you don't need to pass these values again.</p>
<h2 id="what-can-you-do-with-it">What can you do with it?</h2>
<p>Let's run through a few everyday workflows to give you a feel for how ASC works before we get to the really powerful stuff. In reality you would not probably even use these in the command line yourself, since they are pretty difficult to remember, but let's still go through them so when we get to the skills section, we know what is actually happening under the hood.</p>
<h3 id="upload-a-build">Upload a build</h3>
<p>Archive your app from the command line and upload it straight to App Store Connect. The first command here is not related ASC CLI directly, but it's needed so we can upload a build without Xcode.</p>
<pre class="language-bash"><code class="language-bash">xcodebuild clean archive <span class="token punctuation">\</span>
  -scheme <span class="token string">"YourScheme"</span> <span class="token punctuation">\</span>
  -configuration Release <span class="token punctuation">\</span>
  -archivePath /tmp/YourApp.xcarchive <span class="token punctuation">\</span>
  -destination <span class="token string">"generic/platform=iOS"</span>

xcodebuild -exportArchive <span class="token punctuation">\</span>
  -archivePath /tmp/YourApp.xcarchive <span class="token punctuation">\</span>
  -exportPath /tmp/YourAppExport <span class="token punctuation">\</span>
  -exportOptionsPlist ExportOptions.plist
</code></pre>
<p>Then hand the IPA to ASC:</p>
<pre class="language-bash"><code class="language-bash">asc builds upload --app <span class="token string">"APP_ID"</span> --file <span class="token string">"/tmp/YourAppExport/YourApp.ipa"</span>
</code></pre>
<h3 id="submit-for-review">Submit for review</h3>
<p>Once your build has finished processing, one command sends it off:</p>
<pre class="language-bash"><code class="language-bash">asc submit create --app <span class="token string">"APP_ID"</span> --version <span class="token string">"1.2.0"</span> --build <span class="token string">"BUILD_ID"</span> --confirm
</code></pre>
<h3 id="localize-your-store-listing">Localize your store listing</h3>
<p>Pull down your existing localizations, translate them however you like, and push them back up:</p>
<pre class="language-bash"><code class="language-bash">asc localizations download --version <span class="token string">"VERSION_ID"</span> --path <span class="token string">"./localizations"</span>
<span class="token comment"># ... translate the files ...</span>
asc localizations upload --version <span class="token string">"VERSION_ID"</span> --path <span class="token string">"./localizations"</span>
</code></pre>
<p>These are just the basics. ASC has commands for TestFlight groups, encryption declarations, pricing, certificates, and a lot more. But here's where it gets fun.</p>
<h2 id="asc-cli--ai-agents--magic">ASC CLI + AI Agents = Magic</h2>
<p>This is the part I'm most excited about. Rudrank didn't just build a CLI — he also built <a href="https://github.com/rudrankriyam/app-store-connect-cli-skills">19 skills that teach AI agents how to use it</a>. Install them and your AI agent suddenly knows how to manage your App Store presence.</p>
<p>Get started with one command:</p>
<pre class="language-bash"><code class="language-bash">npx skills <span class="token function">add</span> rudrankriyam/app-store-connect-cli-skills
</code></pre>
<p>Pick which AI tool you want the skills installed for (I went with Claude), and you're good to go. Instead of memorizing flags and looking up build IDs, you just talk to your agent in plain language.</p>
<p>The real power is that these skills compose. In a single conversation, you could say: "Build and upload my app, check it's ready for review, localize into Japanese, Korean, French, and German, and show me what's crashing in the latest TestFlight build." That one prompt kicks off four different skills — build, preflight, localize, crash triage — and walks you through an entire release cycle without leaving your editor.</p>
<h3 id="asc-xcode-build"><code>asc-xcode-build</code></h3>
<p>Handles the entire build → archive → export pipeline. No fiddling with <code>xcodebuild</code> flags or hand-crafting <code>ExportOptions.plist</code> files. Just tell your agent "build my app and upload it" and it handles the rest.</p>
<h3 id="asc-release-flow"><code>asc-release-flow</code></h3>
<p>An end-to-end release skill for both TestFlight and App Store, covering iOS, macOS, visionOS, and tvOS. It knows the right order of operations — create a version, attach a build, fill in metadata, submit.</p>
<h3 id="asc-localize-metadata"><code>asc-localize-metadata</code></h3>
<p>This one is a game-changer. It uses LLM-powered translation to localize your App Store descriptions, keywords, what's new text, and subtitles across multiple locales. It knows the character limits (30 for subtitles, 100 for keywords, 4,000 for descriptions) and enforces them automatically.</p>
<h3 id="asc-crash-triage"><code>asc-crash-triage</code></h3>
<p>This skill pulls your TestFlight crash reports, groups them by signature, device, and build, and gives you a plain-language summary of what's breaking and why.</p>
<h3 id="asc-subscription-localization"><code>asc-subscription-localization</code></h3>
<p>If you're using RevenueCat (or just have a lot of in-app purchases), this one bulk-updates subscription and IAP display names across every App Store language at once.</p>
<h2 id="wrapping-up">Wrapping up</h2>
<p>I recently one shotted a small command line app with Claude and ASC CLI, and the only time I had to jump into App Store Connect was to create the app submission. This step you seem to be able to already automate as well, at least what I've been following the ASC CLI skills repo. Funny thing was that Claude even ended up creating a screenshot of the app itself and uploading it, which to be honest was a pretty bad screenshot, but still a screenshot.</p>
<div class="my-6 not-prose"><!--$?--><template id="B:0"></template><div class="react-tweet-theme tweet-container_root__0rJLq tweet-skeleton_root__1sn43"><article class="tweet-container_article__0ERPK"><span class="skeleton_skeleton__gUMqh" style="height:3rem;margin-bottom:0.75rem"></span><span class="skeleton_skeleton__gUMqh" style="height:6rem;margin:0.5rem 0"></span><div style="border-top:var(--tweet-border);margin:0.5rem 0"></div><span class="skeleton_skeleton__gUMqh" style="height:2rem"></span><span class="skeleton_skeleton__gUMqh" style="height:2rem;border-radius:9999px;margin-top:0.5rem"></span></article></div><!--/$--></div>
<p>ASC CLI is one of those tools that makes you wonder why you put up with the web UI for so long. The CLI on its own is already a huge time-saver, but the AI skills are what really set it apart. Being able to say "localize my app into Japanese, Korean, and French" and have it just happen — with character limits enforced and a review step built in — is a massive time saver. You can then use that time to obsess over the UX of your app instead, which is what you should be doing.</p>
<p>Give it a try: <code>brew install asc</code>. Your future self will thank you.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Creating a static websites with Swift and Publish]]></title>
            <link>https://perttu.dev/articles/how-to-use-publish</link>
            <guid isPermaLink="false">https://perttu.dev/articles/how-to-use-publish</guid>
            <pubDate>Fri, 22 May 2020 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>A few days ago, I accidentally stumbled upon <a href="https://github.com/JohnSundell/Publish">Publish</a>, a static site generator that uses Swift as the language for building websites. Despite not being a Swift developer, I thought this sounded quite awesome. So I decided to try if the time I've spent playing around with SwiftUI could be enough to get up and running with a static site built with Swift.</p>
<h3 id="why-use-swift-and-publish">Why Use Swift and Publish?</h3>
<p>Just because something is cool, does mean it's worth doing, and the same applies to building static websites with random languages. Now Swift is hardly a random language. Most people might know it as the "Apple's programming language", and building iOS and macOS apps are what it's mostly used for. However, a lot more has been accomplished with Swift in recent years, after Apple open sourced the language. One of these areas where there's quite a lot of Swift usage is server-side, and a lot of cool <a href="https://github.com/Awesome-Server-Side-Swift/TheList">server-side projects have already been built with Swift</a>.</p>
<p>Being a mostly React and React Native engineer, I'm not the best person to talk about why use Swift to build stuff, having used it so little myself and not knowing all the pros and cons of the language. But let's just say that even from an outsider's perspective, Swift is a pretty good programming language. It has the usual perks of being a modern language, meaning that it borrows a lot of concepts from existing languages which makes it easier to pick up. On top of that, it also seems to be quite a performant language (<a href="http://www.marcinkliks.pl/2015/02/22/swift-vs-others/">based on this slightly old, slightly arbitrary benchmark</a>)</p>
<p>What about Publish then? What makes it good? Looking at the repo and some of the examples sites built with it, it seems that Publish really excels in being quick to get started with, but still allowing you to tweak many things. The basic Publish setup for example comes with RSS, Markdown support, and sitemap, which the most important parts for building a static site. It also supports themes, which you write in Swift using something called <a href="https://github.com/johnsundell/plot">Plot</a>, an HTML theming engine from the same author as Publish.</p>
<h2 id="installing-the-command-line-tools">Installing the command line tools</h2>
<p>Let's start by installing the command line tools by cloning the Publish repository and then running <code>make</code>:</p>
<pre class="language-bash"><code class="language-bash">$ <span class="token function">git</span> clone https://github.com/JohnSundell/Publish.git
$ <span class="token builtin class-name">cd</span> Publish
$ <span class="token function">make</span>
</code></pre>
<p>If you're not that familiar with the <code>make</code> command, it's a part of Make build automation tool that automatically builds programs and libraries from the source code. In order to use make command you need to have Apple developer tools installed. You can install them by running :</p>
<pre class="language-bash"><code class="language-bash">$ xcode-select --install
</code></pre>
<p>You should now have the Publish command line tools installed.</p>
<h2 id="creating-a-new-project-with-publish">Creating a New Project With Publish</h2>
<p>Let's continue out by making a new directory for our project, navigating to it, and then running publish new</p>
<pre class="language-bash"><code class="language-bash">$ <span class="token function">mkdir</span> swift.lahteenlahti.com
$ <span class="token builtin class-name">cd</span> swift.lahteenlahti.com
$ publish new
</code></pre>
<p>After this a new Publish project will be initialized for you. You can open the Package.swift file in Xcode if you want to use that for development, or you can use something VS code. Let's look at what makes up our new static website:</p>
<pre><code>|-- swift.lahteenlahti.com
|   |-- Content
|			|–– posts
|			|–– index.md
|   |-- Resources
|   |-- Sources
</code></pre>
<p><strong>Sources</strong><br>
<!-- -->In this folder you can find your project's main.swift file, which defines some of the basic properties of your website, such as name, language, URL and description. We are going to look at customizing your Publish site in a separate post, so let's leave this file untouched for now.</p>
<p><strong>Content</strong><br>
<!-- -->In the content folder you can find your content in markdown format.</p>
<p><strong>Resources</strong><br>
<!-- -->This folder is empty for now but is later used for theming the project. In the Publish repository you can see that it contains the default FoundationTheme's styles.css file.</p>
<h2 id="building-the-site">Building the Site</h2>
<p>To turn all these files into a static website you need to build the project, which you can do by running the following command:</p>
<pre class="language-bash"><code class="language-bash">$ publish run
</code></pre>
<p>After this you see the Output folder being generated and Publish init a Simple PHP server to serve your site to localhost:8000. If you go that address, you should now see your site. Cool!</p>
<h2 id="publishing-to-netlify">Publishing to Netlify</h2>
<p>One of the coolest things with static websites nowadays is how easy it is to publish them. Just put your code in GitHub or GitLab repository, give Vercel or Netlify access to the repository, and done. I've been using both Netlify and Vercel (haven't yet had a chance to try Gatsby Cloud) to publish my sites, most of which have been built with Gatsby, and they support a variety of different static site generators. But how about something built with Swift?</p>
<p>You could always run the build command on your own computer, and make Netlify serve the already static stuff, but this would kind of defeat the purpose of using a deployment service such as Netlify. However, it turns out that <a href="https://github.com/netlify/build-image/pull/364">Netlify has support for Swift</a>. Let's make our static blog see the outside world by publishing it with Netlify.</p>
<p>First, signup for a Netlify account if you don't already have one, and next make a new GitHub or GitLab repository and allow Netlify to use that. I would also add the Output folder to gitignore. After this you only have to configure the build settings:</p>
<ul>
<li><code>Publish Directory</code> should be <code>Output</code></li>
<li><code>Build Command</code> should be <code>swift run</code></li>
</ul>
<p>That's literally it! if you now go the URL provided by Netlify, you should soon see your blog post there.</p>
<h2 id="whats-next">What's Next?</h2>
<p>I'm personally trying to move towards more native development, so playing around Swift in a context that I'm more familiar with (web) is one way to build on top of existing knowledge. The Publish project we made in this quick tutorial is something I'm going to use to keep track of my upcoming 100daysofSwiftUI journey. In future articles, I'm going to look at how style your Publish blog, and how to add stuff like code snippets.</p>
<h2 id="links">Links</h2>
<ul>
<li><a href="https://github.com/JohnSundell/Publish">Publish</a></li>
<li><a href="https://netlify.com">Netlify</a></li>
<li><a href="https://developer.apple.com/swift/">Swift</a></li>
<li><a href="https://github.com/plahteenlahti/swifting">My example repo</a></li>
</ul>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[React Native In-App Purchases with RevenueCat: iOS & Android Guide]]></title>
            <link>https://perttu.dev/articles/in-app-purchases-rn</link>
            <guid isPermaLink="false">https://perttu.dev/articles/in-app-purchases-rn</guid>
            <pubDate>Tue, 31 Dec 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p>If you want to add in-app purchases to your React Native app, you need to understand the different types (consumables, non-consumables, subscriptions), set up products in App Store Connect and Google Play Console, and use a library like RevenueCat to handle the purchase flow across platforms. This article walks you through the entire process with working code examples.</p>
<hr>
<img alt="Cover image for Making Money with Your React Native App" loading="lazy" width="4096" height="2898" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcover.6c0a5bbf.jpeg&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcover.6c0a5bbf.jpeg&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>This article is based on the talk I gave at the first <a href="https://www.reactnativelondon.co.uk/">React Native London conference in 2024</a> about app monetization and in-app purchases. While selling your app on platforms like MicroAcquire or setting a price tag in the Play Store/App Store are valid strategies, the former isn’t very repeatable, and the latter feels somewhat outdated. In-app purchases have become the modern way to monetize apps. This article covers what you need to know about in-app purchases, how to set them up in your React Native app, and insights from psychology and service design on creating a great in-app purchase experience.</p>
<h3 id="brief-history-of-in-app-purchases">Brief history of in-app purchases</h3>
<p>Apple and Google introduced competing app stores toward the end of the 2010s. Initially, app stores followed a model similar to traditional software sales: users could try a free, limited version of an app, and if they liked it, they could purchase the full version and own it outright. This was the standard way software was sold. However, the shift toward continuous revenue streams eventually took hold, driven by the push for recurring income from customers. Perhaps influenced by this trend, Apple and Google both introduced their own in-app purchase frameworks, changing how apps are monetized.</p>
<p>Today we have in-app purchases for all major platforms and they are most used solution for monetising apps. There are also multiple different types of in-app purchases. Let’s go over them and then talk which one works for your app the best.</p>
<h3 id="consumables">Consumables</h3>
<p>Consumables as the name entails are project that you purchase in the app and consume after purchasing. For example coin packs in mobile games, which you consume and then use to purchase in-game items, or so called token of gratitude purchases such as sponsoring the developer by “buying them a coffee”. In both cases the in-app purchase flow makes it clear that your money is to purchase something that is one time and ephemeral.</p>
<p>Mobile games are the best use case of consumables, as purchasing things is directly tied to the continued experience of playing the game, for example when your in-app purchases allow you to purchase potions to continue playing. Of course you could do something similar with limited use apps, if you have an app that allows user to create AI generated images, then every generation request could be handled as a consumable in-app purchase.</p>
<img alt="Example of a consumable in-app purchase" loading="lazy" width="1440" height="810" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fconsumable.feb6142b.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fconsumable.feb6142b.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fconsumable.feb6142b.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<h3 id="non-consumables">Non-consumables</h3>
<p>Second type of in-app purchase is the non-consumable, which after purchasing stays with the users and never expires. If users uninstall the app, and later install it again, they can recover the non-consumable purchase as well. There are few ways which you can use non-consumables to monetize your app:</p>
<ul>
<li>
<p>Upgrading from lite version of the app to full version. This way there is no need for a separate light version of the app in the app stores.</p>
</li>
<li>
<p>Unlocking an individual feature of the app, such as dark mode. This is similar to the gratitude purchase that consumables offer, as dark mode for example is not a feature user can’t live without (unlike developers would have you believe).</p>
</li>
<li>
<p>Mobile games also use non-consumable purchases to unlock for example clothing for the player character. Alternative to this is to create an in-game currency that is a consumable purchase which is then used to purchase clothing.</p>
</li>
</ul>
<p>Non-consumables used to be a very common approach to providing limited/full-version unlock approach to apps. However the last type of in-app purchases, subscriptions, have mostly taken over this approach so let’s talk about those next.</p>
<h3 id="subscriptions">Subscriptions</h3>
<p>The last and most ubiquous in-app purchase type is subscriptions. In-app purchases provide both recurring subscriptions, that are billed once a month for example; but also non-recurring once, where your subscription is on only for the mentioned timeframe and does not get automatically billed again.</p>
<p>The point of subscriptions is of course to generate continuous revenue, and there a few reasons for this:</p>
<ul>
<li>
<p>Almost everything nowadays is connected to a server, and servers cost money. You have to somehow recoup the monthly server costs and subscriptions is a lot easier way to do that than trying to estimate a fixed price and then making users buy the app for that price.</p>
</li>
<li>
<p>Software is no longer just the app, but quite often there’s a underlying service layer providing e.g. customer support.</p>
</li>
<li>
<p>Software moved from singular releases to continuous development. New features and bug fixes and released periodically.</p>
</li>
<li>
<p>Getting paid for your work. Whether you are a solo developer or part of a big company, monthly revenue is also what turns into your monthly salary (well, one can dream)</p>
</li>
</ul>
<img alt="Example of a subscriptions in Noice app" loading="lazy" width="1440" height="810" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fsubscription.f056269a.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fsubscription.f056269a.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fsubscription.f056269a.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>For these reason subscriptions have become so ubiquous. However building good subscription is not easy and a lot users are still rather subscriptions avoidant. It is not uncommon for example to come across following type of reviews in app stores:</p>
<blockquote>
<p>“App solved my problem but requires a subscription to use the service more than 5 times. One star”</p>
</blockquote>
<p>Because of how easy and likely it is for users to shy away from your app because it has subscriptions, creating great subscription experiences becomes crucial for making money with your app.</p>
<h3 id="understanding-commissions">Understanding commissions</h3>
<p>In-app purchases are just digital payments in mobile app context. However, the you we can’t just slap a Stripe payments into your app to monetize your services and content. Apple and Google both want their pound of flesh in the form of a commission.</p>
<p>The ina-app purchase commission used to be 30% for both App Store and Play store, with both programs offering a reduced 15% commission fee for the first one million in revenue if you enrolled in the small business programs. Going beyond that, and you’ll start paying the original 30% commission. This applies to all in-app purchases, but subscriptions have an additional term to them that makes longer running subscriptions more profitable: both Apple and Google take a reduced 15% commissions that user has been subscribing for more than a year.</p>
<p>To calculate how much money you get when a user pays 10 euros, you have to first deduct the value added tax of your market, and then deduct the commission based on the store and program you’re in. In case of Finland this is 25.5% VAT, which means you’re left with:</p>
<ul>
<li>
<p>Deduct VAT (25.5%): €10 × (1–0.255) = <strong>€7.45</strong></p>
</li>
<li>
<p>Deduct Apple’s commission:</p>
</li>
<li>
<p>If 15%: €7.45 × (1–0.15) = <strong>€6.33</strong></p>
</li>
<li>
<p>If 30%: €7.45 × (1–0.30) = <strong>€5.22</strong></p>
</li>
</ul>
<p>So you’re left with <strong>€6.33 (15%)</strong> or <strong>€5.22 (30%)</strong>.</p>
<p>However, if you’re in a European country, thanks to the new terms of the Digital Markets Act, you can opt-in for Apple’s new reduced commission rate of 17% for all apps, and 10% commission for businesses in small business programs. On top of this, if you’re using Apple’s in-app purchase system, then you need to add 3% on top of either of these to get the full commission. However, if your app downloads exceed over 1 million, you get an extra “Apple’s core technology” fee of 0.5 euros per every download per year. If you’re doing business in Europe you have some choice, but most likely it’s the easiest to get started with the traditional fee model.</p>
<h3 id="reader-app-status---circumventing-the-commission">Reader app status - circumventing the commission</h3>
<p>There is no way to circumvent Apple and Google’s commissions if you’re selling digital services or monetizing your content through your app. Of course if you’re selling physical goods, such as food deliveries, then the in-app purchase rules don’t apply to you.</p>
<p>Alternatively you can also not sell anything in your app, but handle the sales through your own website, using for example Stripe. This is how Kindle and Spotify get around Apple’s limitations. To do the same you have to apply a separate reader app status. To get accepted requires putting your app to be behind a login, and users can only consume already purchased content in the app. All payments need to be done through a separate channel and there are some limitations on how you can direct the user to our own site to do it. Directly linking them into payment process for example will not work, but you can for example say:</p>
<p>“Go to the website to purchase subscription”</p>
<img alt="How Spotify does reader app status" loading="lazy" width="1440" height="810" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Freader_status.28455b61.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Freader_status.28455b61.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Freader_status.28455b61.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<h2 id="in-app-purchases-in-react-native">In-app purchases in React Native</h2>
<p>Since React Native has been around 8 years already, there’s quite a lot of options for in-app purchase for it. For example</p>
<ul>
<li>
<p><a href="https://github.com/hyochan/react-native-iap"><strong>react-native-iap</strong></a> which has been around for sometime. A good choice for consumable purchases but slightly complicated for subscriptions which usually require server side events to manage the subscriptions well.<br>
<em>2.9k stars in GitHub</em></p>
</li>
<li>
<p><a href="https://github.com/chargebee/chargebee-react-native"><strong>react-native-chargebee</strong></a>, a good choice if you’re in a situation where your payment infra already exists in Chargebee. From personal experience, the integration seems more like an afterthought for Chargebee, and its documentation is very confusing, with setting up being needlessly complex.<br>
<em>8 stars in GitHub</em></p>
</li>
<li>
<p><a href="https://github.com/RevenueCat/react-native-purchases"><strong>react-native-purchases</strong></a>, the iap package by RevenueCat. It’s solid, documentation is good, there’s extra stuff like paywalls, and you offer integrations with Stripe for example.<br>
<em>804 stars in GitHub</em></p>
</li>
</ul>
<p>Both Chargebee and RevenueCat offerings are what you would call hosted options, meaning the package is not enough by itself, but instead you will be using their payment infra to manage payments and receipts. In the case of RevenueCat, it’s free to get started with but once you make over 2.5k dollars you start paying 1% commissions on all tracked revenue.</p>
<p>In most cases you want to go with this kind of an approach. Running your own server to manage in-app purchases receipt validation for example is quite the hassle. I recently worked in one project where we had to wire our own consumable purchases to the custom backend and to be honest, I would take almost anything over working with Apple’s in-app server APIs.</p>
<h3 id="setting-up-revenuecat-and-app-store-and-playstore"><strong>Setting up RevenueCat and App Store and Playstore</strong></h3>
<p>In order for things to work correctly we need to do some configuration in RevenueCat and App Store / Play Store. Way things works is that Play Store and App Store need to have products in them that we then connect to RevenueCat. This is easier to explain by example so start navigate to your App Store connect account and first create this subscription group</p>
<p>After creating the subscription group we need to add two subscriptions into it, and then add the translations. We don’t need so submit anything yet but it necessary to get into a stage that says “Ready to submit”.</p>
<img alt="Example of how to create a subscription group in App Store Connect" loading="lazy" width="1440" height="1153" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fappstore.1849ac0c.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fappstore.1849ac0c.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fappstore.1849ac0c.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>You should end up with something similar to this once. Missing metadata means that you still have to fill in things, although the subscriptions should already show up in sandboxtesting.</p>
<p>Next step is to configure these in RevenueCat. You can do this manually or you can configure your App Store keys into RevenueCat and allow RevenueCat to import your products from App Store. Before we get into all that it’s important to note that RevenueCat offers quite a lot more configuration options for in-app purchases than Play Store and App Store, and they also use custom concepts for it. Let’s look at few of the key ones before continuing with the configuration:</p>
<p><strong>Entitlements —</strong> put simply this is the level of access user is entitled to. Your app could have multiple levels of entitlements that unlock different levels of features for example. In terms of real world examples, Netflix offers different subscriptions, which are similar to entitlements: basic one with 720p resolution and only one device, and family one with 4k and multiple devices.</p>
<p><strong>Products</strong> are what RevenueCat calls the subscriptions and other in-app purchases you’ve set up in App Store connect and Play Store console. Products should match the subscriptions we’ve set up earlier.</p>
<p><strong>Offerings</strong> allows us to map together different products and show them together. RevenueCat’s paywall functionality for example makes use of the offerings</p>
<p><strong>Paywall</strong> is a method for limiting access to content, and RevenueCat offers a built-in functionality to create these and configure them remotely without any code.</p>
<p>Now that you understand these concepts you should understand the next steps for configuring our in-app purchases in RevenueCat dashboard. Due to having a monthly and yearly subscription for our app in App Store connect, we need to create two corresponding product, one entitlement that is connected to those products called <em>Pro subscription</em>, and one offering which will have both of those products. Once that is done, navigate to the <em><strong>Paywalls</strong></em> section of the tool and create our first paywall with the one offering we made.</p>
<img alt="Example products in RevenueCat" loading="lazy" width="1440" height="765" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Frevenuecat.a7c31f6a.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Frevenuecat.a7c31f6a.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Frevenuecat.a7c31f6a.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>You should now have the fundamentals building blocks for showing two subscription options in our React Native app. You can go ahead and customize the paywall to your needs. Once you ready, continue to next section which goes over the react-native-purchases configuration.</p>
<img alt="Example of the paywall editor in RevenueCat" loading="lazy" width="1440" height="1172" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fpaywall.1952ba52.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fpaywall.1952ba52.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fpaywall.1952ba52.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<h3 id="adding-and-configuring-react-native-purchases">Adding and configuring react-native-purchases</h3>
<p>React-native-purchases provides a lot of functionality out of the box without having to write a lot of code. You probably already have a project in which you’re going to configure your in-app purchases, but in case you do not, you can clone the repository below which contains the basic setup and the code examples we are going to go through next</p>
<p><strong>Example project: <a href="https://github.com/plahteenlahti/buystuff">plahteenlahti/buystuff</a></strong></p>
<p>Everything begins with installing the necessary packages, <code>react-native-purchases</code> and <code>react-native-purchases-ui</code>. The latter will become more important later on when we start to work with the paywalls.</p>
<p>After installing all the necessary packages, we need to initialize the <code>react-native-purchases</code> module when mounting our app:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> useEffect <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react'</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> <span class="token maybe-class-name">Platform</span> <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native'</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">Purchases</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native-purchases'</span><span class="token punctuation">;</span>

<span class="token function">useEffect</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token comment">// Enable dev logging, you can remove this line</span>
  <span class="token maybe-class-name">Purchases</span><span class="token punctuation">.</span><span class="token method function property-access">setLogLevel</span><span class="token punctuation">(</span><span class="token maybe-class-name">Purchases</span><span class="token punctuation">.</span><span class="token constant">LOG_LEVEL</span><span class="token punctuation">.</span><span class="token constant">DEBUG</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

  <span class="token comment">// Initialize the module using Platform.select</span>
  <span class="token maybe-class-name">Purchases</span><span class="token punctuation">.</span><span class="token method function property-access">configure</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
    apiKey<span class="token operator">:</span> <span class="token maybe-class-name">Platform</span><span class="token punctuation">.</span><span class="token method function property-access">select</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
      ios<span class="token operator">:</span> <span class="token string">'your_ios_api_key'</span><span class="token punctuation">,</span>
      android<span class="token operator">:</span> <span class="token string">'your_android_api_key'</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><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><span class="token punctuation">;</span>
</code></pre>
<p>It’s important to notice that <code>apiKey</code> is a single field but our Android and iOS apps have different key. Using <code>Platform.select</code> we can easily get around this. The API key is a public key so we don’t have to worry about the key getting bundled without our app.</p>
<p>With this done, we are ready to start restricting user access to the app and presenting paywalls.</p>
<h3 id="before-purchasedisplaying-a-paywall">Before purchase — Displaying a paywall</h3>
<p>We are going to talk about the best possible way to structure you in-app purchase flow, which usually doesn’t start with instantly showing a paywall. However to keep the examples clear, let’s put aside for now and instead focus on the code implementation and not the UX.</p>
<p>As mentioned, paywalls are a way of restricting user’s access to certain content or functionality in your app. Let’s say for example that user is trying to download a wallpaper app, and they press on the wallpaper they are interested in seeing in full screen. Instead of opening in full screen, they are taken to a new screen which tells them to subscribe for 2.99€ a month. A prime example of a paywall. With RevenueCat, achieving similar functionality is very easy:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">RevenueCatUI</span></span> <span class="token keyword module">from</span> <span class="token string">"react-native-purchases-ui"</span><span class="token punctuation">;</span>

<span class="token keyword module">export</span> <span class="token keyword module">default</span> <span class="token keyword">function</span> <span class="token function"><span class="token maybe-class-name">App</span></span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">[</span>showPaywall<span class="token punctuation">,</span> setShowPaywall<span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token function">useState</span><span class="token punctuation">(</span><span class="token boolean">false</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">SafeAreaView</span></span> <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>styles<span class="token punctuation">.</span><span class="token property-access">container</span><span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">ScrollView</span></span> <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>styles<span class="token punctuation">.</span><span class="token property-access">container</span><span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
        </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">StatusBar</span></span> <span class="token special-attr"><span class="token attr-name">style</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span><span class="token value css language-css">auto</span><span class="token punctuation">"</span></span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
        </span><span class="token punctuation">{</span>images<span class="token punctuation">.</span><span class="token method function property-access">map</span><span class="token punctuation">(</span><span class="token punctuation">(</span>imageUrl<span class="token punctuation">,</span> index<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span>
          <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">TouchableOpacity</span></span>
            <span class="token attr-name">key</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>index<span class="token punctuation">}</span></span>
            <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>styles<span class="token punctuation">.</span><span class="token property-access">imageContainer</span><span class="token punctuation">}</span></span>
            <span class="token attr-name">onPress</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token function">setShowPaywall</span><span class="token punctuation">(</span><span class="token boolean">true</span><span class="token punctuation">)</span><span class="token punctuation">}</span></span>
          <span class="token punctuation">&gt;</span></span><span class="token plain-text">
            </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Image</span></span>
              <span class="token attr-name">source</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">{</span> uri<span class="token operator">:</span> imageUrl <span class="token punctuation">}</span><span class="token punctuation">}</span></span>
              <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>styles<span class="token punctuation">.</span><span class="token property-access">image</span><span class="token punctuation">}</span></span>
              <span class="token attr-name">resizeMode</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>cover<span class="token punctuation">"</span></span>
            <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
          </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">TouchableOpacity</span></span><span class="token punctuation">&gt;</span></span>
        <span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">}</span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">ScrollView</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">

      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Modal</span></span>
        <span class="token attr-name">presentationStyle</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>pageSheet<span class="token punctuation">"</span></span>
        <span class="token attr-name">visible</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>showPaywall<span class="token punctuation">}</span></span>
        <span class="token attr-name">animationType</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>slide<span class="token punctuation">"</span></span>
        <span class="token attr-name">onRequestClose</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token function">setShowPaywall</span><span class="token punctuation">(</span><span class="token boolean">false</span><span class="token punctuation">)</span><span class="token punctuation">}</span></span>
      <span class="token punctuation">&gt;</span></span><span class="token plain-text">
        </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">RevenueCatUI.Paywall</span></span>
          <span class="token attr-name">style</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">{</span> flex<span class="token operator">:</span> <span class="token number">1</span> <span class="token punctuation">}</span><span class="token punctuation">}</span></span>
          <span class="token attr-name">onDismiss</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token function">setShowPaywall</span><span class="token punctuation">(</span><span class="token boolean">false</span><span class="token punctuation">)</span><span class="token punctuation">}</span></span>
          <span class="token attr-name">options</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">{</span> displayCloseButton<span class="token operator">:</span> <span class="token boolean">true</span> <span class="token punctuation">}</span><span class="token punctuation">}</span></span>
        <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Modal</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">SafeAreaView</span></span><span class="token punctuation">&gt;</span></span>
  <span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>In the code above I want you to pay attention to few things. First, in order to show the RevenueCat paywall you need the react-native-purchases-ui package that was installed earlier. Second is how I’ve structured the actual paywall. Whether to show the paywall or not is dependant on the showPayWall state. Later on we will tie this to react-native-purchases methods for checking user’s entitlements, but this simple approach is enough for now.</p>
<p>I wanted to display the paywall inside a modal that jumps on top whatever is on the screen and wrapped it in React Native’s modal component. In a more complex app you could make the paywall part of the navigation stack, which opens up more possibilities for displaying for it.</p>
<img alt="Paywall in action after code changes" loading="lazy" width="800" height="450" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fpaywall.20ce08b4.gif&amp;w=828&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fpaywall.20ce08b4.gif&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fpaywall.20ce08b4.gif&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>Once you’ve implemented this part of the code, you should see the paywall you configured in RevenueCat. If nothing is showing up it’s time to check your configurations and the debug log for potential errors. Try also making changes to your paywall in RevenueCat’s dashboard and you should see them reflected on the app after a refresh. To test purchases you need to run the app on a real device and configure a sandbox testing account for that, see <a href="https://developer.apple.com/help/app-store-connect/test-in-app-purchases/create-a-sandbox-apple-account/">Apple’s up to date guide on how to do that here</a></p>
<h3 id="after-purchasechecking-users-entitlements">After purchase — Checking user’s entitlements</h3>
<p>Now that we have our basic purchasing flow set up using the RevenueCat’s paywall functionality, it’s time to check user’s subscription status i.e. are they entitled to the content they are trying to access. In revenuecat this is done by calling the await Purchases.getCustomerInfo() and checking if the users active entitlements contain the entitlement that is required to access that content. CustomerInfo is cached by RevenueCat so it’s safe to call in multiple times, therefore the easiest way to implement entitlement checking feature is to create a custom hook:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> useState<span class="token punctuation">,</span> useEffect <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react'</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">Purchases</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native-purchases'</span><span class="token punctuation">;</span>

<span class="token keyword module">export</span> <span class="token keyword">const</span> <span class="token function-variable function">useAppContentAccess</span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">[</span>hasAccess<span class="token punctuation">,</span> setHasAccess<span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token function">useState</span><span class="token punctuation">(</span><span class="token boolean">false</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">const</span> <span class="token punctuation">[</span>loading<span class="token punctuation">,</span> setLoading<span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token function">useState</span><span class="token punctuation">(</span><span class="token boolean">true</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

  <span class="token function">useEffect</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
    <span class="token keyword">const</span> <span class="token function-variable function">checkCustomerEntitlement</span> <span class="token operator">=</span> <span class="token keyword">async</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
      <span class="token keyword control-flow">try</span> <span class="token punctuation">{</span>
        <span class="token function">setLoading</span><span class="token punctuation">(</span><span class="token boolean">true</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
        <span class="token keyword">const</span> customerInfo <span class="token operator">=</span> <span class="token keyword control-flow">await</span> <span class="token maybe-class-name">Purchases</span><span class="token punctuation">.</span><span class="token method function property-access">getCustomerInfo</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

        <span class="token comment">// Replace 'pro_access' with the actual entitlement ID you set in RevenueCat</span>
        <span class="token keyword">const</span> entitlementActive <span class="token operator">=</span> customerInfo<span class="token operator">?.</span>entitlements<span class="token operator">?.</span>active<span class="token operator">?.</span><span class="token punctuation">[</span><span class="token string">'pro_access'</span><span class="token punctuation">]</span><span class="token punctuation">;</span>

        <span class="token function">setHasAccess</span><span class="token punctuation">(</span><span class="token operator">!</span><span class="token operator">!</span>entitlementActive<span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token comment">// User has access if entitlement is active</span>
      <span class="token punctuation">}</span> <span class="token keyword control-flow">catch</span> <span class="token punctuation">(</span>error<span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token console class-name">console</span><span class="token punctuation">.</span><span class="token method function property-access">error</span><span class="token punctuation">(</span><span class="token string">"Error checking customer entitlement:"</span><span class="token punctuation">,</span> error<span class="token punctuation">)</span><span class="token punctuation">;</span>
        <span class="token function">setHasAccess</span><span class="token punctuation">(</span><span class="token boolean">false</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token comment">// Default to no access on error</span>
      <span class="token punctuation">}</span> <span class="token keyword control-flow">finally</span> <span class="token punctuation">{</span>
        <span class="token function">setLoading</span><span class="token punctuation">(</span><span class="token boolean">false</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 function">checkCustomerEntitlement</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><span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

  <span class="token keyword control-flow">return</span> <span class="token punctuation">{</span> hasAccess<span class="token punctuation">,</span> loading <span class="token punctuation">}</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>
</code></pre>
<p>You can use this hook in all parts of the app that require checking the user’s access level to that subscription content like this:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">React</span></span> <span class="token keyword module">from</span> <span class="token string">'react'</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> <span class="token maybe-class-name">View</span><span class="token punctuation">,</span> <span class="token maybe-class-name">Text</span><span class="token punctuation">,</span> <span class="token maybe-class-name">ActivityIndicator</span> <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native'</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports">useAppContentAccess</span> <span class="token keyword module">from</span> <span class="token string">'./hooks/useAppContentAccess'</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">App</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">{</span> hasAccess<span class="token punctuation">,</span> loading <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useAppContentAccess</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

  <span class="token keyword control-flow">if</span> <span class="token punctuation">(</span>loading<span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword control-flow">return</span> <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">ActivityIndicator</span></span> <span class="token punctuation">/&gt;</span></span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>

  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">View</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token punctuation">{</span>hasAccess <span class="token operator">?</span> <span class="token punctuation">(</span>
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">Welcome to the premium content!</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span>
      <span class="token punctuation">)</span> <span class="token operator">:</span> <span class="token punctuation">(</span>
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">Please subscribe to access this content.</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span>
      <span class="token punctuation">)</span><span class="token punctuation">}</span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">View</span></span><span class="token punctuation">&gt;</span></span>
  <span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>

<span class="token keyword module">export</span> <span class="token keyword module">default</span> <span class="token maybe-class-name">App</span><span class="token punctuation">;</span>
</code></pre>
<p>You now have all the fundamentals building blocks for building in-app purchase flows in your React Native app. Let’s next look at the fundamentals behind good in-app purcase flows and a little bit of user psychology.</p>
<h2 id="psychology-of-good-in-app-purchase-experiences">Psychology of good in-app purchase experiences</h2>
<img alt="Apple promoting subscriptions in App Store" loading="lazy" width="1440" height="810" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fpromotions.dabe7a55.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fpromotions.dabe7a55.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fpromotions.dabe7a55.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<h3 id="getting-user-to-buy">Getting user to buy</h3>
<p>In app purchases are not the main use case of your app, you should not be spending time on building them, they bring no value to the users and I don’t think I’ve ever heard anyone say “I love building in-app purchases”. So sure, you can build your in-app purchases on anything you want, build your custom stuff for it if you feel, but keep in mind that no user is ever going to give you money or 5 star rating because your in-app purchase screen was artisanally made.</p>
<p>What you should spend your time instead is thinking of how to build the best in-app purchase flow. What I mean by that is thinking about the user goals and actions that lead them to first discover how your app solves their problems, and then commit to paying it to you. We should start by first thinking about what is the user problem our app is focused on solving for the user. That is after all why we’ve built the app originally. That functionality should of course be very visible in all the App Store and Play store listings so so that users download the app in the first place. Of course it should also be so that main functionality is the easiest one to find from to find from both the store’s marketing as well as when the user opens your app the first time.</p>
<p>Once we have the problem clearly in mind it’s time to think about how we show that our app has that exact functionality that they are looking for. For this we have few options:</p>
<ul>
<li>
<p>Can we allow the users to use functionality in the app and get results and then limit it. For example, in our wallpaper app we could maybe allow the users to download one wallpaper for free</p>
</li>
<li>
<p>Can we give the users a free trial of the full app? For example let’s say that when they subscribe for a month they get 1 week free during which they can cancel the subscription</p>
</li>
<li>
<p>Give something for free, a little bit more for some amount of money, and then give full access to everything for money. A ladder-esque approach where user becomes a more engaged customer as a function of feature amount</p>
</li>
</ul>
<h3 id="life-cycle-of-in-app-purchases">Life cycle of in-app purchases</h3>
<p>Depending what you’re selling in your app, the customer journey is always different. One time purchases and continuous subscriptions pose different challenges for the customer journey. For time sake I’m only going to use subscriptions as an example of how to map your customer journey:</p>
<ul>
<li>
<p>User discovers app</p>
</li>
<li>
<p>User discovers the value promise of the subscription</p>
</li>
<li>
<p>User decides to commit to buying subscription</p>
</li>
<li>
<p>User decides to increase spending in the app</p>
</li>
<li>
<p>User churns, ends subscription</p>
</li>
</ul>
<p>Of course in an optimal world the user never churns out, but in real life people run out of money, their priorities change, or your app stops being valuable. Only the last one is something you directly improve. For the other parts you can only provide a stellar experience, so the problems you’re trying to solve becomes how to support users in all parts of the customer journey:</p>
<ul>
<li>
<p>Highlight new value of the app</p>
</li>
<li>
<p>Offer free trials</p>
</li>
<li>
<p>Offer discounts on longer subscription</p>
</li>
<li>
<p>Highlight how old purchase is pro-rated</p>
</li>
</ul>
<img alt="Upselling in Duolingo" loading="lazy" width="1440" height="810" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fupsell.c7ef3985.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fupsell.c7ef3985.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fupsell.c7ef3985.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<h2 id="summary">Summary</h2>
<p>You've made it to the end, to recap you should now have a pretty good understanding of in-app purchase on different platforms, the options for doing that in React Native, example of in-app purchases using Revenucat and react-native-purchases, and the user psychology of a good in-app purchase experience. With these you should be able to create a basic in-app purchase flow that converts users to paying users. If you're interested in learning more I suggest looking at RevenueCat's developer documentation which contains a lot good information and technical aspects of in-app purchases.</p>
<hr>
<h2 id="related-articles">Related articles</h2>
<ul>
<li><a href="/articles/react-native-in-app-purchases-revenuecat">React Native In-App Purchases with RevenueCat: Complete Guide (2026)</a> - Updated step-by-step RevenueCat setup guide</li>
<li><a href="/articles/react-native-paywall-revenuecat-purchases-ui">Building a Subscription Paywall in React Native with RevenueCat Purchases UI</a> - Pre-built paywalls, A/B testing, and remote configuration</li>
<li><a href="/articles/mobile-app-monetization-guide">How to Monetize Your Mobile App in 2026</a> - Complete guide to app monetization strategies for iOS &amp; Android</li>
<li><a href="/articles/how-to-make-money-with-your-android-app">How to make money with your Android app</a> - Android-specific monetization strategies</li>
<li><a href="/articles/flatlists-with-react-query">React Query + FlatList best practices</a> - Build smooth list UIs for your app</li>
</ul>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Localizing dates in React Native the right way]]></title>
            <link>https://perttu.dev/articles/localizing-dates-in-react-native-the-right-way</link>
            <guid isPermaLink="false">https://perttu.dev/articles/localizing-dates-in-react-native-the-right-way</guid>
            <pubDate>Tue, 10 Oct 2023 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>There are a lot of things the world can't seem to agree on, and one of them is how to format dates.</p>
<blockquote>
<p>Girl: "What's your idea of a perfect date?"</p>
<p>Engineer: "DD/MM/YYYY. I find other formats a bit confusing."</p>
</blockquote>
<p>Dates can be structured in a multitude of ways:</p>
<ul>
<li>DD/MM/YYYY (most of Europe, Australia)</li>
<li>MM/DD/YYYY (United States)</li>
<li>YYYY/MM/DD (ISO 8601, East Asia)</li>
<li>DD.MM.YYYY (Germany, Finland, Russia)</li>
<li>YYYY.MM.DD (Hungary, Lithuania)</li>
<li>DD-MM-YYYY (Netherlands, South Africa)</li>
<li>YYYY-MM-DD (ISO 8601, Sweden, Canada)</li>
</ul>
<p>What varies is both the separator between the numbers and the order of day, month, and year. In the US, the most common format is MM/DD/YYYY. In most of Europe, it's DD/MM/YYYY or DD.MM.YYYY.</p>
<p>I've built enough UIs over the years to know that eventually you'll need to localize your dates. I remember particularly well a case where we tried to push the European date format to US users. We received a lot of user feedback—zero positive. Turns out Americans don't appreciate you pushing your "superior" date format on them.</p>
<p>Now that we've established the problem, let's look at the solution.</p>
<h2 id="the-challenge-with-react-native">The Challenge with React Native</h2>
<p>Date localization in React Native is an interesting topic. If you navigate to Settings on iOS, you'll find a section in General → Language &amp; Region that shows the user's preferred date and time formats. These settings are based on the language and region configured on the device.</p>
<p>The challenge is that React Native doesn't directly expose these system preferences. You can't simply ask the OS "what date format does this user prefer?" Instead, we need to combine a few tools to achieve proper localization.</p>
<h2 id="the-solution-intldatetimeformat--react-native-localize">The Solution: Intl.DateTimeFormat + react-native-localize</h2>
<p>The approach combines two things:</p>
<ol>
<li><strong>Intl.DateTimeFormat</strong> — A native JavaScript API for locale-aware date formatting</li>
<li><strong>react-native-localize</strong> — A library that exposes the device's locale information</li>
</ol>
<h3 id="step-1-install-react-native-localize">Step 1: Install react-native-localize</h3>
<p>First, install the library:</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">npm</span> <span class="token function">install</span> react-native-localize
<span class="token comment"># or</span>
<span class="token function">yarn</span> <span class="token function">add</span> react-native-localize
</code></pre>
<p>For iOS, run pod install:</p>
<pre class="language-bash"><code class="language-bash"><span class="token builtin class-name">cd</span> ios <span class="token operator">&amp;&amp;</span> pod <span class="token function">install</span> <span class="token operator">&amp;&amp;</span> <span class="token builtin class-name">cd</span> <span class="token punctuation">..</span>
</code></pre>
<h3 id="step-2-understanding-intldatetimeformat">Step 2: Understanding Intl.DateTimeFormat</h3>
<p><code>Intl.DateTimeFormat</code> is part of the ECMAScript Internationalization API (ECMA-402). It's supported in modern JavaScript engines, including Hermes (React Native's default engine since 0.70).</p>
<p>Here's a basic example:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword">const</span> date <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Date</span></span><span class="token punctuation">(</span><span class="token string">'2023-10-15'</span><span class="token punctuation">)</span>

<span class="token comment">// US English format</span>
<span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span><span class="token string">'en-US'</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">format</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span> <span class="token comment">// "10/15/2023"</span>

<span class="token comment">// Finnish format</span>
<span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span><span class="token string">'fi-FI'</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">format</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span> <span class="token comment">// "15.10.2023"</span>

<span class="token comment">// German format</span>
<span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span><span class="token string">'de-DE'</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">format</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span> <span class="token comment">// "15.10.2023"</span>

<span class="token comment">// Japanese format</span>
<span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span><span class="token string">'ja-JP'</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">format</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span> <span class="token comment">// "2023/10/15"</span>
</code></pre>
<p>The beauty of <code>Intl.DateTimeFormat</code> is that it handles all the complexity of different locales for you. You just need to provide the correct locale code.</p>
<h3 id="step-3-getting-the-device-locale">Step 3: Getting the Device Locale</h3>
<p>This is where <code>react-native-localize</code> comes in. It provides the <code>getLocales()</code> function that returns an array of the user's preferred locales:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> getLocales <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native-localize'</span>

<span class="token keyword">const</span> locales <span class="token operator">=</span> <span class="token function">getLocales</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
<span class="token console class-name">console</span><span class="token punctuation">.</span><span class="token method function property-access">log</span><span class="token punctuation">(</span>locales<span class="token punctuation">)</span>
<span class="token comment">// [</span>
<span class="token comment">//   { countryCode: "FI", languageTag: "fi-FI", languageCode: "fi", isRTL: false },</span>
<span class="token comment">//   { countryCode: "US", languageTag: "en-US", languageCode: "en", isRTL: false }</span>
<span class="token comment">// ]</span>
</code></pre>
<p>The first item in the array is the user's primary locale. We can use the <code>languageTag</code> (e.g., "fi-FI") directly with <code>Intl.DateTimeFormat</code>.</p>
<h3 id="step-4-putting-it-together">Step 4: Putting It Together</h3>
<p>Here's a simple utility function to format dates according to the user's locale:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> getLocales <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native-localize'</span>

<span class="token keyword module">export</span> <span class="token keyword">const</span> formatLocalizedDate <span class="token operator">=</span> <span class="token punctuation">(</span>date<span class="token operator">:</span> <span class="token known-class-name class-name">Date</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">[</span><span class="token punctuation">{</span> languageTag <span class="token punctuation">}</span><span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token function">getLocales</span><span class="token punctuation">(</span><span class="token punctuation">)</span>

  <span class="token keyword control-flow">return</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span>languageTag<span class="token punctuation">,</span> <span class="token punctuation">{</span>
    day<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    month<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    year<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">format</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token comment">// Usage</span>
<span class="token keyword">const</span> date <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Date</span></span><span class="token punctuation">(</span><span class="token string">'2023-10-15'</span><span class="token punctuation">)</span>
<span class="token function">formatLocalizedDate</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span> <span class="token comment">// "15.10.2023" for Finnish users, "10/15/2023" for US users</span>
</code></pre>
<h2 id="advanced-getting-the-format-pattern-string">Advanced: Getting the Format Pattern String</h2>
<p>Sometimes you need the format pattern itself (like "DD.MM.YYYY") rather than a formatted date. This is useful when you're working with date picker libraries or displaying placeholder text.</p>
<p>The trick is to use <code>formatToParts()</code>, which breaks down the formatted date into its components:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> getLocales <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native-localize'</span>

<span class="token keyword module">export</span> <span class="token keyword">const</span> getDateFormatString <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">[</span><span class="token punctuation">{</span> languageTag <span class="token punctuation">}</span><span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token function">getLocales</span><span class="token punctuation">(</span><span class="token punctuation">)</span>

  <span class="token keyword control-flow">return</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span>languageTag<span class="token punctuation">,</span> <span class="token punctuation">{</span>
    day<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    month<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    year<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">formatToParts</span><span class="token punctuation">(</span><span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Date</span></span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">map</span><span class="token punctuation">(</span><span class="token punctuation">(</span>part<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
      <span class="token keyword control-flow">switch</span> <span class="token punctuation">(</span>part<span class="token punctuation">.</span><span class="token property-access">type</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token keyword">case</span> <span class="token string">'day'</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> <span class="token string">'DD'</span>
        <span class="token keyword">case</span> <span class="token string">'month'</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> <span class="token string">'MM'</span>
        <span class="token keyword">case</span> <span class="token string">'year'</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> <span class="token string">'YYYY'</span>
        <span class="token keyword module">default</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> part<span class="token punctuation">.</span><span class="token property-access">value</span> <span class="token comment">// This preserves separators like ".", "/", "-"</span>
      <span class="token punctuation">}</span>
    <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">join</span><span class="token punctuation">(</span><span class="token string">''</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token comment">// Returns "DD.MM.YYYY" for Finnish, "MM/DD/YYYY" for US</span>
</code></pre>
<h2 id="working-with-date-fns">Working with date-fns</h2>
<p>If you're using <code>date-fns</code> for date manipulation (and you probably should be), you can combine the format string with its <code>format</code> function:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> format <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'date-fns'</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> getLocales <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native-localize'</span>

<span class="token keyword">const</span> getDateFormatString <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">[</span><span class="token punctuation">{</span> languageTag <span class="token punctuation">}</span><span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token function">getLocales</span><span class="token punctuation">(</span><span class="token punctuation">)</span>

  <span class="token keyword control-flow">return</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span>languageTag<span class="token punctuation">,</span> <span class="token punctuation">{</span>
    day<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    month<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    year<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">formatToParts</span><span class="token punctuation">(</span><span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Date</span></span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">map</span><span class="token punctuation">(</span><span class="token punctuation">(</span>part<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
      <span class="token keyword control-flow">switch</span> <span class="token punctuation">(</span>part<span class="token punctuation">.</span><span class="token property-access">type</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token keyword">case</span> <span class="token string">'day'</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> <span class="token string">'dd'</span>
        <span class="token keyword">case</span> <span class="token string">'month'</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> <span class="token string">'MM'</span>
        <span class="token keyword">case</span> <span class="token string">'year'</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> <span class="token string">'yyyy'</span>
        <span class="token keyword module">default</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> part<span class="token punctuation">.</span><span class="token property-access">value</span>
      <span class="token punctuation">}</span>
    <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">join</span><span class="token punctuation">(</span><span class="token string">''</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token keyword module">export</span> <span class="token keyword">const</span> formatLocalizedDate <span class="token operator">=</span> <span class="token punctuation">(</span>date<span class="token operator">:</span> <span class="token known-class-name class-name">Date</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token function">format</span><span class="token punctuation">(</span>date<span class="token punctuation">,</span> <span class="token function">getDateFormatString</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Note that date-fns uses lowercase <code>dd</code> for day and <code>yyyy</code> for year, which differs from the common uppercase notation.</p>
<h2 id="handling-time-formats">Handling Time Formats</h2>
<p>The same approach works for time formatting. Users have strong preferences about 12-hour vs 24-hour time formats:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword module">export</span> <span class="token keyword">const</span> formatLocalizedTime <span class="token operator">=</span> <span class="token punctuation">(</span>date<span class="token operator">:</span> <span class="token known-class-name class-name">Date</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">[</span><span class="token punctuation">{</span> languageTag <span class="token punctuation">}</span><span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token function">getLocales</span><span class="token punctuation">(</span><span class="token punctuation">)</span>

  <span class="token keyword control-flow">return</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span>languageTag<span class="token punctuation">,</span> <span class="token punctuation">{</span>
    hour<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    minute<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">format</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token comment">// "2:30 PM" for US users, "14:30" for most European users</span>
</code></pre>
<h2 id="complete-utility-module">Complete Utility Module</h2>
<p>Here's a complete utility module you can drop into your project:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token comment">// utils/dateLocalization.ts</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> getLocales <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native-localize'</span>

<span class="token keyword">const</span> getLocale <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token punctuation">[</span><span class="token punctuation">{</span> languageTag <span class="token punctuation">}</span><span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token function">getLocales</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
  <span class="token keyword control-flow">return</span> languageTag
<span class="token punctuation">}</span>

<span class="token keyword module">export</span> <span class="token keyword">const</span> formatLocalizedDate <span class="token operator">=</span> <span class="token punctuation">(</span>date<span class="token operator">:</span> <span class="token known-class-name class-name">Date</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span><span class="token function">getLocale</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token punctuation">{</span>
    day<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    month<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    year<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">format</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token keyword module">export</span> <span class="token keyword">const</span> formatLocalizedDateTime <span class="token operator">=</span> <span class="token punctuation">(</span>date<span class="token operator">:</span> <span class="token known-class-name class-name">Date</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span><span class="token function">getLocale</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token punctuation">{</span>
    day<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    month<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    year<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    hour<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    minute<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">format</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token keyword module">export</span> <span class="token keyword">const</span> formatLocalizedTime <span class="token operator">=</span> <span class="token punctuation">(</span>date<span class="token operator">:</span> <span class="token known-class-name class-name">Date</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span><span class="token function">getLocale</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token punctuation">{</span>
    hour<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    minute<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">format</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token keyword module">export</span> <span class="token keyword">const</span> formatRelativeDate <span class="token operator">=</span> <span class="token punctuation">(</span>date<span class="token operator">:</span> <span class="token known-class-name class-name">Date</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span><span class="token function">getLocale</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token punctuation">{</span>
    month<span class="token operator">:</span> <span class="token string">'short'</span><span class="token punctuation">,</span>
    day<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token method function property-access">format</span><span class="token punctuation">(</span>date<span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token keyword module">export</span> <span class="token keyword">const</span> getDateFormatPattern <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Intl</span></span><span class="token punctuation">.</span><span class="token method function property-access"><span class="token maybe-class-name">DateTimeFormat</span></span><span class="token punctuation">(</span><span class="token function">getLocale</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token punctuation">{</span>
    day<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    month<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
    year<span class="token operator">:</span> <span class="token string">'numeric'</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">formatToParts</span><span class="token punctuation">(</span><span class="token keyword">new</span> <span class="token class-name"><span class="token known-class-name class-name">Date</span></span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">map</span><span class="token punctuation">(</span><span class="token punctuation">(</span>part<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
      <span class="token keyword control-flow">switch</span> <span class="token punctuation">(</span>part<span class="token punctuation">.</span><span class="token property-access">type</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token keyword">case</span> <span class="token string">'day'</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> <span class="token string">'DD'</span>
        <span class="token keyword">case</span> <span class="token string">'month'</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> <span class="token string">'MM'</span>
        <span class="token keyword">case</span> <span class="token string">'year'</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> <span class="token string">'YYYY'</span>
        <span class="token keyword module">default</span><span class="token operator">:</span>
          <span class="token keyword control-flow">return</span> part<span class="token punctuation">.</span><span class="token property-access">value</span>
      <span class="token punctuation">}</span>
    <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token method function property-access">join</span><span class="token punctuation">(</span><span class="token string">''</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>
</code></pre>
<h2 id="gotchas-and-tips">Gotchas and Tips</h2>
<h3 id="hermes-and-intl-support">Hermes and Intl Support</h3>
<p>If you're using Hermes (the default since React Native 0.70), <code>Intl</code> is fully supported. For older versions or if Hermes is disabled, you might need to polyfill it with packages like <code>intl</code> or <code>@formatjs/intl-datetimeformat</code>.</p>
<h3 id="caching-the-locale">Caching the Locale</h3>
<p>The locale doesn't change during app usage (unless the user changes their device settings and restarts the app). Consider caching the locale value:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword">let</span> cachedLocale<span class="token operator">:</span> <span class="token builtin">string</span> <span class="token operator">|</span> <span class="token keyword null nil">null</span> <span class="token operator">=</span> <span class="token keyword null nil">null</span>

<span class="token keyword">const</span> getLocale <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>cachedLocale<span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword">const</span> <span class="token punctuation">[</span><span class="token punctuation">{</span> languageTag <span class="token punctuation">}</span><span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token function">getLocales</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    cachedLocale <span class="token operator">=</span> languageTag
  <span class="token punctuation">}</span>
  <span class="token keyword control-flow">return</span> cachedLocale
<span class="token punctuation">}</span>
</code></pre>
<h3 id="testing-different-locales">Testing Different Locales</h3>
<p>During development, you can test different locales by changing the language and region settings on your simulator or device. On iOS Simulator: Settings → General → Language &amp; Region.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Proper date localization isn't just a nice-to-have—it's essential for any app with international users. The combination of <code>Intl.DateTimeFormat</code> and <code>react-native-localize</code> gives you everything you need to display dates in a format your users expect.</p>
<p>The key takeaways:</p>
<ol>
<li>Never hardcode date formats—use <code>Intl.DateTimeFormat</code> with the user's locale</li>
<li>Use <code>react-native-localize</code> to get the device's locale settings</li>
<li>The <code>formatToParts()</code> method is your friend when you need the format pattern string</li>
<li>The same approach works for time formatting</li>
</ol>
<p>Your American users will thank you for showing them 10/15/2023 instead of 15.10.2023.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Fix 'VirtualizedLists should never be nested inside plain ScrollViews' in React Native]]></title>
            <link>https://perttu.dev/articles/react-native-virtualizedlists-nested-inside-scrollview</link>
            <guid isPermaLink="false">https://perttu.dev/articles/react-native-virtualizedlists-nested-inside-scrollview</guid>
            <pubDate>Sat, 13 Jun 2020 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>When developing with React Native and nesting <code>FlatList</code> or <code>SectionList</code> components inside a plain <code>ScrollView</code>, your debugger might display the following warning:</p>
<div class="my-6 rounded-lg border p-2 border-red-200 bg-red-50/50 dark:border-red-800/50 dark:bg-red-900/20"><div class="flex gap-2"><div class="shrink-0 text-red-600 dark:text-red-400"><svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"></path></svg></div><div class="flex-1 min-w-0"><div class="mb-1.5 font-semibold text-red-900 dark:text-red-200">React Native Error</div><div class="prose-xs prose-zinc dark:prose-invert max-w-none text-xs my-0"><p>VirtualizedLists should never be nested inside plain ScrollViews with the same
orientation because it can break windowing and other functionality - use another
VirtualizedList-backed container instead.</p></div></div></div></div>
<p>This warning pretty much tells what it is about. What it doesn't tell is why this is bad and how to fix the warning (other than changing the orientation of the nested VirtualizedList, but that is not always possible). Let's look at why this happens and how to fix it.</p>
<h3 id="why-nesting-virtualizedlist-inside-a-plain-scrollview-is-bad">Why nesting VirtualizedList inside a plain ScrollView is bad?</h3>
<p>Virtualized lists, such as <code>&lt;SectionList&gt;</code> and <code>&lt;FlatList&gt;</code>, are performance-optimized, meaning they massively improve memory consumption and performance when rendering large lists of content. The way this optimization works is by rendering only the content currently visible in the window, usually the container/screen of your device. It also replaces all other list items with same-sized blank spaces and renders them based on your scrolling position.</p>
<p>Now, if you put either of these two lists inside a ScrollView, they fail to calculate the size of the current window and will instead try to render everything, possibly causing performance problems, and, of course, it will also give you the warning mentioned before.</p>
<h3 id="how-to-fix-this-warning-the-right-way">How to fix this warning the right way</h3>
<p>The fix to this warning is simpler than you think: get rid of the <code>ScrollView</code>, and place all the components that surround the <code>FlatList</code> inside <code>ListFooterComponent</code> and <code>ListHeaderComponent</code>.</p>
<p>Let's see how this works in practice. In this example, we have an app where the user can scroll through different recipes. The main view consists of a <code>ScrollView</code>, and inside that view, we have a collection of components such as a header, a footer, some text, and a cover photo. It looks something like this:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">Main</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">ScrollView</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">CoverPhoto</span></span> <span class="token attr-name">src</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>images<span class="token punctuation">[</span><span class="token number">0</span><span class="token punctuation">]</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Title</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">Welcome</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Title</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">Take a look at the list of recipes below:</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Footer</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">ScrollView</span></span><span class="token punctuation">&gt;</span></span>
  <span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>
</code></pre>
<p>Now, say we want to list all the recipes under the last Text element using <code>FlatList</code>, after which it would look something like this:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">Main</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token function-variable function">renderItem</span> <span class="token operator">=</span> <span class="token punctuation">(</span>item<span class="token punctuation">,</span> index<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
    <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
      <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Recipe</span></span>
        <span class="token attr-name">key</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>index<span class="token punctuation">}</span></span>
        <span class="token attr-name">recipe</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>item<span class="token punctuation">}</span></span>
      <span class="token punctuation">/&gt;</span></span>
    <span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span><span class="token punctuation">;</span>

  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">ScrollView</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">CoverPhoto</span></span> <span class="token attr-name">src</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>images<span class="token punctuation">[</span><span class="token number">0</span><span class="token punctuation">]</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Title</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">Welcome</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Title</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">Take a look at the list of recipes below:</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">FlatList</span></span>
        <span class="token attr-name">data</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>recipes<span class="token punctuation">}</span></span>
        <span class="token attr-name">renderItem</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>renderItem<span class="token punctuation">}</span></span>
      <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Footer</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">ScrollView</span></span><span class="token punctuation">&gt;</span></span>
  <span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>
</code></pre>
<p>This will, of course, give you the warning mentioned before, and you will also not be able to use the performance features <code>FlatList</code> offers. That is why we are going to get rid of the <code>ScrollView</code> completely and instead use the <code>ListFooterComponent</code> and <code>ListHeaderComponent</code> props like so:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">Main</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> <span class="token function-variable function">renderItem</span> <span class="token operator">=</span> <span class="token punctuation">(</span>item<span class="token punctuation">,</span> index<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
    <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
      <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Recipe</span></span>
        <span class="token attr-name">key</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>index<span class="token punctuation">}</span></span>
        <span class="token attr-name">recipe</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>item<span class="token punctuation">}</span></span>
      <span class="token punctuation">/&gt;</span></span>
    <span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span><span class="token punctuation">;</span>

  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">FlatList</span></span>
      <span class="token attr-name">ListHeaderComponent</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span>
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
          </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">CoverPhoto</span></span> <span class="token attr-name">src</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>images<span class="token punctuation">[</span><span class="token number">0</span><span class="token punctuation">]</span><span class="token punctuation">}</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
          </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Title</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">Welcome</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Title</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
          </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">Take a look at the list of recipes below:</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Text</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
        </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span></span><span class="token punctuation">&gt;</span></span>
      <span class="token punctuation">)</span><span class="token punctuation">}</span></span>
      <span class="token attr-name">data</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>recipes<span class="token punctuation">}</span></span>
      <span class="token attr-name">renderItem</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>renderItem<span class="token punctuation">}</span></span>
      <span class="token attr-name">ListFooterComponent</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span>
        <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Footer</span></span> <span class="token punctuation">/&gt;</span></span>
      <span class="token punctuation">)</span><span class="token punctuation">}</span></span>
    <span class="token punctuation">/&gt;</span></span>
  <span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>
</code></pre>
<p>Both props only accept one component, so we wrapped the components in the <code>ListHeaderComponent</code> with Fragments. But there you have it: no more warnings, and everything still looks the same.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[React Native WebRTC]]></title>
            <link>https://perttu.dev/articles/react-native-webrtc</link>
            <guid isPermaLink="false">https://perttu.dev/articles/react-native-webrtc</guid>
            <pubDate>Mon, 30 Sep 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>This is a work in progress article version of the talk I gave in Krakow at RTC.ON Conference 2024.
In this article, I’ll guide you through using WebRTC in React Native. We’ll start with the motivations behind real-time media on mobile, introduce React Native WebRTC, and finally explore the limitations and potential improvements for mobile implementations.</p>
<h2 id="why-webrtc-on-mobile">Why WebRTC on Mobile?</h2>
<p>Real-time communication isn’t just a feature for companies anymore; it’s something customers expect. People want to video call doctors, live stream content, or chat with friends—functionality that’s increasingly common across various apps. With mobile phones being the primary device for many people to stay connected, building for mobile becomes a natural first step for real-time communication applications.</p>
<h2 id="why-react-native">Why React Native?</h2>
<p>WebRTC on the web has proven to be fairly straightforward, and thankfully, the complexity doesn’t have to increase significantly on mobile—thanks to React Native. In case you’re unfamiliar, React Native is essentially JavaScript-based technology that enables cross-platform app development for iOS, Android, TVs, and even cars. What makes it effective is its ability to leverage web knowledge in a mobile environment and stay close to native APIs.</p>
<p>React Native provides a relatively quick way to get apps up and running in the app stores, and in my experience, the cross-platform aspect is highly efficient.</p>
<h2 id="how-to-get-started">How to Get Started</h2>
<p>To start a new React Native project, I recommend using Expo, a framework that simplifies access to native APIs, deployment, and rapid prototyping. To create a new Expo app:</p>
<p><code>npx create-expo-app@latest</code></p>
<p>Run the project with:</p>
<p><code>npx expo start</code></p>
<p>Now we have one small thing we need to take in to consideration, we need to run npx expo prebuild so that we can start using the native code parts as well.</p>
<p><code>npx expo prebuild</code></p>
<p>Next we need to think about how to integrate WebRTC to our project. We thankfully don’t have to look far since in react-native it is usually enough to just write “react native” and the technology you’re interested in, so let’s do react-native-webrtc and lo and behold, we have react native webrtc package!</p>
<h2 id="react-native-webrtc">React Native WebRTC</h2>
<p>Most of the functionality that react-native-webrtc provides is identical to that of web. So for example these are most likely familiar to you if you’ve worked on WebRTC before. If not, worry not, let’s look at some basic things:</p>
<pre class="language-javascript"><code class="language-javascript"><span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span>
	<span class="token maybe-class-name">ScreenCapturePickerView</span><span class="token punctuation">,</span>
	<span class="token maybe-class-name">RTCPeerConnection</span><span class="token punctuation">,</span>
	<span class="token maybe-class-name">RTCIceCandidate</span><span class="token punctuation">,</span>
	<span class="token maybe-class-name">RTCSessionDescription</span><span class="token punctuation">,</span>
	<span class="token maybe-class-name">RTCView</span><span class="token punctuation">,</span>
	<span class="token maybe-class-name">MediaStream</span><span class="token punctuation">,</span>
	<span class="token maybe-class-name">MediaStreamTrack</span><span class="token punctuation">,</span>
	mediaDevices<span class="token punctuation">,</span>
	registerGlobals
<span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'react-native-webrtc'</span><span class="token punctuation">;</span>
</code></pre>
<h3 id="getting-local-stream">Getting local stream</h3>
<p>WebRTC provides standard APIs for accessing cameras and microphones on computers and smartphones, same goes for React Native WebRTC. We can access these devices through the get mediaDevices API:</p>
<pre class="language-javascript"><code class="language-javascript"><span class="token keyword">const</span> mediaStream <span class="token operator">=</span> <span class="token keyword control-flow">await</span> mediaDevices<span class="token punctuation">.</span><span class="token method function property-access">getUserMedia</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>By default react-native-webrtc expects us to send both audio and video. To limit this we can define media constraints:</p>
<pre class="language-javascript"><code class="language-javascript"><span class="token keyword">let</span> mediaConstraints <span class="token operator">=</span> <span class="token punctuation">{</span>
	<span class="token literal-property property">audio</span><span class="token operator">:</span> <span class="token boolean">true</span><span class="token punctuation">,</span>
	<span class="token literal-property property">video</span><span class="token operator">:</span> <span class="token punctuation">{</span>
		<span class="token literal-property property">frameRate</span><span class="token operator">:</span> <span class="token number">30</span><span class="token punctuation">,</span>
		<span class="token literal-property property">facingMode</span><span class="token operator">:</span> <span class="token string">'user'</span>
	<span class="token punctuation">}</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>
</code></pre>
<p>If we want to share our screen we can use</p>
<pre class="language-javascript"><code class="language-javascript"><span class="token keyword">const</span> mediaStream <span class="token operator">=</span> <span class="token keyword control-flow">await</span> mediaDevices<span class="token punctuation">.</span><span class="token method function property-access">getDisplayMedia</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>To actually display anything we can make use of the <code>&lt;RTCView /&gt;</code> component to render both local and remote streams:</p>
<p>And there we have it, a very quick, but working implementation of WebRTC on React Native.</p>
<h3 id="additional-packages-for-a-full-video-calling-app">Additional Packages for a Full Video Calling App</h3>
<p>For a complete calling experience, consider adding:</p>
<p>•	react-native-incall-manager: Manages events like headset plug-ins.
•	react-native-callkeep: Integrates your app with native call screens.</p>
<h2 id="webrtc-across-platforms">WebRTC Across Platforms</h2>
<p>The beauty of React Native WebRTC is that it works seamlessly on iOS and Android, requiring minimal platform-specific code—only permissions differ between platforms. Performance holds up well, though lower-end Android devices may struggle slightly.</p>
<p>Beyond mobile, React Native WebRTC opens possibilities for platforms like tvOS and even web through React Native Web. The react-native-webrtc-web-shim allows you to extend your React Native app to the web, making it possible to build a mobile-first, cross-platform app supporting WebRTC.</p>
<h2 id="limitations-of-react-native-webrtc">Limitations of React Native WebRTC</h2>
<p>While react-native-webrtc offers a solid foundation, it’s primarily geared toward voice and video calls. For example, if you’re using it solely for streaming, the package still requires microphone permissions, which can be a roadblock. In our project, we customized our instance of react-native-webrtc to avoid this.</p>
<p>Another limitation is the lack of picture-in-picture (PIP) support on mobile. PIP currently only functions within the app, not when users switch to other apps. However, recent developments suggest iOS PIP support might be on the way.</p>
<h2 id="conclusion">Conclusion</h2>
<p>React Native WebRTC makes implementing real-time communication feasible on mobile, with extensive cross-platform support. While there are limitations, its flexibility allows it to serve various real-time media needs. Whether you’re building a video-calling app or incorporating streaming features, React Native WebRTC is a powerful choice that could simplify your development process.</p>
<img alt="Perttu Lähteenlahti speaking at RTC.ON Conference 2024" loading="lazy" width="5472" height="3648" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fspeaking.b5e7e7bf.jpg&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fspeaking.b5e7e7bf.jpg&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Running React Native apps on specific iOS simulators]]></title>
            <link>https://perttu.dev/articles/running-react-native-on-ios-simulator</link>
            <guid isPermaLink="false">https://perttu.dev/articles/running-react-native-on-ios-simulator</guid>
            <pubDate>Thu, 31 Oct 2019 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>If you're developing React Native apps for iOS you're most likely familiar with this command:</p>
<pre class="language-bash"><code class="language-bash">react-native run-ios
</code></pre>
<p>In most cases that command opens up the iPhone X simulator. Instead, if you want to open your React Native app in a specific simulator you can also add the wanted device name with the simulator flag like this:</p>
<pre class="language-bash"><code class="language-bash">react-native run-ios <span class="token assign-left variable">simulator</span><span class="token operator">=</span><span class="token string">'iPhone 8'</span>
</code></pre>
<p>And it will open up the iPhone 8 Simulator. You can get the full list of available devices with the command</p>
<pre class="language-bash"><code class="language-bash">xcrun simctl list devices
</code></pre>
<p>And here's a list of all the available devices available for testing:</p>
<pre class="language-bash"><code class="language-bash">react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 5s"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 6"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 6 Plus"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 6s"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 6s Plus"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 7"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 7 Plus"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 8"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 8 Plus"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone SE"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone X"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone XR"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone XS"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone XS Max"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 11"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 11 Pro"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone 11 Pro Max"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPhone XS Max"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPad Air"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPad Air 2"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPad"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPad Pro"</span>
react-native run-ios --simulator<span class="token operator">=</span><span class="token string">"iPad"</span>
</code></pre>
<h3 id="im-getting-could-not-find-iphone-x-simulator-or-similar-error-message-when-running-this-command">I'm getting "Could not find iPhone X simulator" or similar error message when running this command</h3>
<p>At times you might run into problems with this command. This error message, for example, is quite common:</p>
<pre class="language-bash"><code class="language-bash">Could not <span class="token function">find</span> iPhone X simulator

Error: Could not <span class="token function">find</span> iPhone X simulator
</code></pre>
<p>This often caused by updating to a new Xcode version which doesn't include the iPhone X simulator any more, which is the default for react-native-cli. This problem should disappear when you pass the simulator flag and another device than iPhone X.</p>
<p>This was a quick and simple guide to running on different iOS simulators when building React Native apps. I wrote this because I found myself googling different device names too often. I hope that you find it useful 🙂</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Is Se7en Based on a Book? Reviewing the screenplay and novelization]]></title>
            <link>https://perttu.dev/articles/screenwriting-vs-novel-writing-and-the-se7en-novelization</link>
            <guid isPermaLink="false">https://perttu.dev/articles/screenwriting-vs-novel-writing-and-the-se7en-novelization</guid>
            <pubDate>Tue, 26 Mar 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2 id="introduction-novels-vs-movies--who-gets-the-credit">Introduction: Novels vs. movies — who gets the credit?</h2>
<p>If you're quickly asked to name three of your favorite books and their authors, you probably won't have to think about it too long. However, asking anyone to name their three favorite movies and then asking to name the people who wrote the screenplays for them is definitely harder. Even the biggest movie buffs have a hard time doing that. For most of us, movies are formed by the directors and actors. Who came up with the story seems less important. With novels we have almost the exact opposite: authors are celebrated, and sometimes who wrote the book weighs more than the story being told. But why? Aren't screenwriting and novel writing just different ways of telling stories?</p>
<h2 id="at-first-glance-similar-story-structures">At first glance: Similar story structures</h2>
<p>On the surface, screenwriting and novel writing seem very similar: you take interesting characters, create a story that makes them go through events that conflict with their beliefs, and eventually reach the end of the story with changed characters. Or that's how it goes if you're writing a monomyth.</p>
<h2 id="the-fundamental-differences-scope-character-depth--time">The fundamental differences: Scope, character depth &amp; time</h2>
<p>How much you can develop the characters and how many life-changing events you can throw in your character's journey differ a lot between screenwriting and novels; a book you read for 6 hours has too much content to fit into a blockbuster movie, and script for a feature-length movie can easily be read through in under an hour.</p>
<p>Underneath the surface of screenwriting, the differences between novels become evident. While many novels are treated as exemplary students who proudly carry the author's name, screenplays are disowned offspring, monospaced bastards. As the script goes through the adoption process of becoming a movie, it sheds its familial connections to the original screenwriter. It becomes a product of the tribe of actors and directors.</p>
<p>Perhaps because it's easier to strip the story to its central themes than it is to build a compelling book from a simple story premise, it seems to be more common for novelists to end up writing scripts or modifying their original stories into screenplays. Take for example Gillian Flynn, author of books such as Gone Girl, Sharp Things, and Dark Places, all of these have been turned into movies or television productions, Flynn being a screenwriter on Gone Girl and co-writer of Sharp Things.</p>
<h2 id="why-screenwriters-are-often-invisible">Why screenwriters are often invisible</h2>
<p>Screenwriting is also a rather weird and disrespected profession. You churn out scripts and submit them; if you’re really good, someone might pick up your script for further development. Eventually, even a movie might come out that vaguely resembles your original screenplay. Or if you wrote the script for the 2003 Daredevil, you get a version that got run over by a movie exec in a Humvee and reanimated with Evanescence. That’s just the nature of movies. After all, they are “productions,” communal art projects rather than singular visions.</p>
<div class="flex items-center justify-center flex-col"><iframe width="560" height="315" src="https://www.youtube.com/embed/_nnOcgElmMc" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"></iframe><div class="[&amp;>p]:mt-0 text-xs"><p>When you watch the video above, you think the kids on playground are going
like "Yeah, blind guy! Beat that woman's ass!" or are they more on the side
"Fuck yeah, girl power! Kick the shit out of that blind dude!"?</p></div></div>
<p>It's no wonder that screenwriting does not get the attention that it (in rare cases) deserves. Hollywood seems to treat screenwriters akin to sperm donors: you give out the goods, we raise your kid, and in turn, you get to enjoy reverse child support. Alternatively, you might get paid only once when handing in the script for the first time, just like the good old times when selling little jack to the mines could net you enough food to survive the winter.</p>
<h2 id="case-study-the-making-of-se7en">Case study: The making of Se7en</h2>
<p>The 1995 movie Se7en is the aptest example of the nature of screenwriting. Our story starts in the early nineties, with an aspiring screenwriter Andrew Kevin Walker (other notable works: The Killer, 8mm, Sleep Hollow) landing in New York City and getting so depressed about the bleak city and its crime problems that they decide to write a thriller about police officers on the hunt for a serial killer with a biblical thirst for committing murders in the style of the Se7en deadly sins. If that does not sound like the simplest but, at the same time, the raddest plot line, read the premise again while listening to Nine Nine-Inch Nails Closer.</p>
<p>Now, a good script isn’t enough by itself. You also need someone to produce it into a movie. In the case of Se7en, the script went through a few studios before eventually landing at Newline Cinema, which was finally ready to adopt this child. However, as it often is with foster kids who get bounced from one home to another, Se7en was considered too dark. The infamous ending was considered so bleak that Walker had to do multiple rewrites. You can find one of these rewritten scripts on the internet, and the ending part is definitely a choice with the story ending in a shootout in a church. I can just picture movie execs going “a movie about Se7en deadly sins ending in a church, get it? Eh? Eeeh?”</p>
<p>As mentioned, the movie we know has a different ending, which can only be attributed to someone accidentally sending the original script to David Fincher, who would become the movie's director, instead of the alleyway doctored version. Fincher mixed in some of his signature style (if you ever find your life having desaturated colors and corruption in seemingly prosperous settings, you're either depressed or in David Fincher movie), baptizing the script into the violent neo-noir classic we know as the Se7en.</p>
<h2 id="from-script-to-novel-the-se7en-novelization">From script to novel: The Se7en novelization</h2>
<p>At some point, before Se7en came to theaters in September 1995, someone decided that the movie needed a book tie-in. I’m not sure how common this was at the time or how common it is even nowadays, so one can only guess who thought the movie needed this and why. Perhaps someone at Newline was worried that the movie would bomb and hoped that a novelization would regain some of the dollars that went into production. Whatever the reason, Anthony Bruno was commissioned to write the novelization (with Walker getting credit for the original screenplay in the books).</p>
<p>There is not much information concerning Bruno or his thoughts on writing the novelization. Based on a quick Goodreads skim, it seems Bruno has made his career writing non-fiction and fiction crime books, with the latest published in 2014. Perhaps a bit surprisingly, the Se7en novelization appears to be his highest-rated work, with 4.23 stars on Goodreads (although this is based on only a bit over 4000 reviews).</p>
<p>With around 250 pages, Se7en is a very short book, something an average reader would go through in 7-8 hours. Now, considering that the book revolves around Se7en deadly sins-themed murders, we can quickly calculate that on average, the murder discussed in the book would get only 35 pages to introduce the murder victim, murder scene, and characters related to the murder. This is very little page estate to use, making Se7en feel like sprinting through cliff notes.</p>
<h2 id="comparing-the-script-the-film--the-novelization">Comparing the script, the film &amp; the novelization</h2>
<p>Although the book feels like it was written very quickly, with both redundancy and continuity errors, the novelization does develop the characters of Somerset and Mills a bit further. Somerset’s nihilistic worldview, for example, becomes more fleshed out, turning Somerset’s “Ernest Hemingway once wrote, ‘The world is a fine place and worth fighting for.’ I agree with the second part.” quote into something that reflects more on the character inner turmoil rather than being a cliche of forced positivity it is in the movie.</p>
<p>But does reading the novelization bring additional value over watching the movie? Well, it’s certainly more difficult to get through if you’re glancing at your Instagram at the same time, so it must feel like there’s more depth to it. But I think the real question is whether the novelization beats reading the original script. For that, the answer is more multifaceted. Reading scripts requires you to picture the scene to bring depth to the story actively:</p>
<img alt="Part of Se7en's script" loading="lazy" width="1410" height="998" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fscript.439f55da.png&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fscript.439f55da.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fscript.439f55da.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>In the above scene, for example, very little is explained about how the characters talk to each other: the focus is on what is being said. This is deliberate, allowing actors to bring personality and depth to the characters. If you're capable of putting yourself into the shoes of an imaginary director and reading the script as if you were getting ready to shoot it, then go with reading the script instead of the novelization (also it's available for free on the internet).</p>
<h2 id="personal-reflection-why-this-story-resonates">Personal reflection: Why this story resonates</h2>
<p>Despite its shortcomings, the novelization of Se7en has had a special place in my heart ever since I accidentally stumbled on it in my father's collections when I was ten. It is one of the only two books I’ve ever read more than once, the second time being after I had seen the movie more than 5 times already. I enjoy both the book and the movie for different reasons. The movie’s acting, characters, cinematography, and camera work are something I find myself coming back to because similar combinations seem to be much rarer in this age of comic book movies. For me, the movie is a video essay on how to tell a great story on a screen.</p>
<p>The book has none of those qualities. It's a clumsily written movie tie-in from an unknown author, and for most people, that's all it is.</p>
<h2 id="conclusion-medium-matters--but-story-transcends">Conclusion: Medium matters — but story transcends</h2>
<p>But for me, it's a love letter to how a good story transcends its medium.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[How to style contents in dangerouslySetInnerHTML with Tailwind]]></title>
            <link>https://perttu.dev/articles/styling-dangerouslySetInnerHtml-with-tailwind</link>
            <guid isPermaLink="false">https://perttu.dev/articles/styling-dangerouslySetInnerHtml-with-tailwind</guid>
            <pubDate>Sat, 27 Jan 2024 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>I recently needed to style the Html content that was injected into an element using the <a href="https://legacy.reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml"><code>dangerouslySetInnerHTML</code></a> property. Since the other parts of the project were using Tailwind, I wanted to continue the same approach here instead of writing custom CSS for that part. Turns out Tailwind makes this relatively easy using the <a href="https://tailwindcss.com/docs/hover-focus-and-other-states#using-arbitrary-variants">arbitrary variants</a>. This allowed me to do the following:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">Tooltip</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">{</span> content <span class="token punctuation">}</span><span class="token operator">:</span> <span class="token punctuation">{</span> content<span class="token operator">:</span> <span class="token builtin">string</span> <span class="token punctuation">}</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span>
    <span class="token operator">&lt;</span>div className<span class="token operator">=</span><span class="token string">"bg-white"</span><span class="token operator">&gt;</span>
        <span class="token operator">&lt;</span>div className<span class="token operator">=</span><span class="token string">"[&amp;&gt;p]:text-zinc-800"</span>
            dangerouslySetInnerHTML<span class="token operator">=</span><span class="token punctuation">{</span><span class="token punctuation">{</span> __html<span class="token operator">:</span> content <span class="token punctuation">}</span><span class="token punctuation">}</span>
        <span class="token operator">/</span><span class="token operator">&gt;</span>
    <span class="token operator">&lt;</span><span class="token operator">/</span>div<span class="token operator">&gt;</span>
<span class="token punctuation">)</span>
</code></pre>
<p>Essentially you can target any element using the <code>[&amp;&gt;element]</code> syntax. Tailwind itself for example has an example of <code>[&amp;:nth-child(3)]</code>, and in case you need to style for example all the <code>p</code> elements in the dangerouslySetInnerHTML, not just the direct descendants as in the example above, you can use underscore: <code>&amp;_p</code> as replacement for space in your selector.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Successful rapid prototyping with React Native]]></title>
            <link>https://perttu.dev/articles/successful-rapid-prototyping-with-react-native</link>
            <guid isPermaLink="false">https://perttu.dev/articles/successful-rapid-prototyping-with-react-native</guid>
            <pubDate>Sun, 29 Dec 2019 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>This is a transcript of a lightning talk I gave at React Day Berlin in 2019. You can watch the talk <a href="https://www.youtube.com/embed/QafikEOSUGA">here</a>. All the links resources are available at the end of this transcript.</p>
<p>But before we go into all that, I want to give a little introduction to myself. So the reason for my complicated name "Perttu Lähteenlahti" is because I'm from Finland, which is by the way celebrating its Independence Day today. I'm a developer/designer at a company called Nyxo, where we build personalized sleep coaching programs in a mobile app format.</p>
<p>The reason I know a lot about prototypes is because I've taken part in around 70 hackathons, which are, in a way, prototyping competitions. I've won about 40 of those, so I've been moderately successful.</p>
<p>But let's get to the subject at hand, so why prototype? Reasons for doing that could be simply that:</p>
<ul>
<li>You're starting from scratch</li>
<li>You want to try out new technology</li>
<li>You're unsure of the path the product is going to take</li>
</ul>
<p>Generally speaking, prototyping is something you do every time you're unsure what you're supposed to do. That's precisely the place where I started about a year ago when I was hired by two university research to commercialize their sleep research. I went to the first meeting, and the conversation went a little like this:</p>
<p><strong>Researchers:</strong> <em>"build us a product."</em></p>
<p><strong>Me:</strong> <em>"can you be more specific?"</em></p>
<p><strong>Researchers:</strong> <em>"build as a product that is nice to use and makes money."</em></p>
<p>I was like that doesn't make it any more apparent, but then I decided that I might as well start by building a prototype.</p>
<p>I ended up building these three prototypes. In the one you see on the left of the picture, we decided to experiment with how people would like to see their sleep data. What kind of presentation types work and which do not.</p>
<img alt="Three prototypes I build" loading="lazy" width="2556" height="1346" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fprototypes.e8e241b0.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fprototypes.e8e241b0.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>In the middle one, we tested out how you should provide sleep coaching content, which means, e.g., how to show exercises and lessons that are engaging and interesting to go through.</p>
<p>And on the right one, we tested that if you could make sleeping into a game so every night you would try to sleep better than your friends and that way compete against your friends.</p>
<p>When I was researching how to build these prototypes, I had one hard requirement: I have able to take part in the coding as well. Because I have a background in design and web development, native iOS or Android weren't a viable option. React Native turned out to be a kind of a perfect solution for that. But not just because it's suitable for prototyping but because I could also use it to build the end product. This transition is possible because building with React Native is relatively straightforward to build with it. It's also really fast to build with it, and it's even faster to deliver and measure everything.</p>
<h2 id="building">Building</h2>
<p>Let's look at why is building apps with React Native is so swift. For starters, the are many great UI kits such as react-native-paper. I like to build UIs myself. Mainly because I will then know how every component is built, and can then use them more effectively. In general, React Native allows you to build cool stuff fast just because you can employ your existing skills from React and JavaScript so much.</p>
<h2 id="delivering">Delivering</h2>
<p>What is maybe even cooler is delivering fast. React Native's capability to deliver fast actually saved our butts when we were testing one of our pilot products with the largest life insurance company in Finland. One Friday I got an email that said:</p>
<p>"hey, this feature we agreed on doesn't work for me."</p>
<p>Reading that email, I was like we never agreed on that feature. It was never supposed to be in this pilot. However, instead of arguing over email, I instead decided to try if I could fix things without anyone noticing. So during the weekend, I built the feature and released it before Monday. Without React Native that would not have been possible, because the usual way you do it through App Store connect and Play Store is really painful it takes many days for Apple to look at but using Codepush we were able to update the code. The users didn't even notice that the feature was missing. The following Monday I got an email saying "sorry it might have been my device that it didn't work."</p>
<p>Expo is a pretty good solution, also offering you the same capabilities of pushing your code over-the-air as CodePush.</p>
<p>Last but not least, there's also react-native-remote-config that is part of React Native Firebase. It isn't as powerful as Codepush or Expo, but still allows you to make changes on the fly, but only in the configuration as you can change the JavaScript bundle by using this. It is still worth considering, especially if you're already using Firebase in your project.</p>
<h2 id="analytics">Analytics</h2>
<p>Before becoming a product designer/developer, I was a carpenter for seven years. There we used the mantra "measure twice" a lot. However, when I transitioned to the technology world, this mantra turned into a "measure everything". Measure what the user is doing, how long they are doing it, and what they are doing. Because in prototyping, your progress is only as good as results and feedback you get you. To do that, use the following tools.</p>
<p><strong>Amplify analytics from AWS</strong></p>
<p><strong>Firebase Analytics</strong></p>
<p><strong>App Center Analytics (we use this mostly)</strong></p>
<p>Here is also one hack that reveals some hidden user behavior. Wrap all the components with different <code>&lt;TouchableWithoutFeedback/&gt;</code> components and make them emit and analytics event when the user clicks them. It allows you to understand which parts of the screen users are pressing, and for example, reveals if they consider something to be clickable that is not. In our case, it showed both UX problems as well and potential new features. However, please don't do this in production; it's terrible for accessibility.</p>
<img alt="Analytics hack" loading="lazy" width="2556" height="1354" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fhack.70ed93c9.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fhack.70ed93c9.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>This concludes my lightning talk on Successful rapid prototyping.</p>
<ul>
<li>Build fast with<!-- -->
<ul>
<li><a href="https://reactnativepaper.com/">React Native Paper</a></li>
</ul>
</li>
<li>Deliver fast<!-- -->
<ul>
<li><a href="https://expo.io">Expo</a></li>
<li><a href="https://github.com/Microsoft/code-push">CodePush</a></li>
<li><a href="https://invertase.io/oss/react-native-firebase/v6/remote-config">Remote Config</a></li>
</ul>
</li>
<li>Measure everything.<!-- -->
<ul>
<li><a href="https://aws-amplify.github.io/">AWS Amplify</a></li>
<li><a href="https://appcenter.ms">App Center</a></li>
<li><a href="https://rnfirebase.io/docs/v5.x.x/analytics/ios">Firebase</a></li>
</ul>
</li>
</ul>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[The role of designers' subjective interpretations in human-centered design]]></title>
            <link>https://perttu.dev/articles/the-role-of-designers-subjective-interpretations-in-human-centered-design</link>
            <guid isPermaLink="false">https://perttu.dev/articles/the-role-of-designers-subjective-interpretations-in-human-centered-design</guid>
            <pubDate>Tue, 20 Oct 2015 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h5 id="interpretation-is-the-basis-of-radical-innovation">Interpretation is the basis of radical innovation</h5>
<p><em>Design is the process of making sense of things, as well as solving the problems that are found through sense-making. Human-centered design is an approach that aims to incorporate the perspective of the user into this process so that the needs of the users are addressed better, which in turn leads to better solutions. In human-centered design, the role of the designer is to formulate the solutions by understanding the user, not by acting on the insights by the designer himself. This article evaluates the previous statement through aspects found in the literature. I start by proposing that human-centered design is always affected by the designers’ perception of the surrounding world and users. I call this collection of emotions, beliefs, and other human properties, the “designers ’ flavor,’ and continue by proposing that it plays a central role in innovation. The claims surrounding incremental and radical innovation are evaluated against the proposition of designers having an important role in igniting innovation.</em></p>
<h3 id="introduction">Introduction</h3>
<p>To design is to create a plan or convention that will lead to a construction of an object, service system, or possibly even an experience. It is a direct result of human decisions and happens whether you do it while aware of it or not. As such, there are no human-made things that didn’t have a design, and the question lies in whether the item is a product of good design or bad design or something residing in between these two opposites. This leads to another way of concretizing the term design by calling it a property of an object that communicates the function of the object through it’s designed to form.</p>
<p>For example, if we imagine a person that is like us in every manner except that said person had never encountered a bucket. Now let us imagine a situation where this person encounters a bucket for the first time in his life. For a short time, this person would not have the slightest idea of the use of the bucket, but in good time this person would learn to use the bucket, for example, to transport water from well. This is a scenario of understanding the bucket’s function as a vessel is only possible because of the form of the bucket.</p>
<p>Additionally, to merely being the property that communicates function, the design is also the process that leads to solving of a problem by making sense of things [4]. The design of the bucket and the bucket itself was not born from a void; it was the result of both encountering a problem of transporting water from a well, and solving that problem. The solution was born from noticing a hindrance in life and solving it by understanding that the solution is constrained by the ones affected by the design. Such is the essence of human-centered design. But understanding human-centered design is not possible by merely reducing it to two components. If we want to inspect the way designers solve these problems through human-centered methods, we have to examine the process from the ground up.</p>
<p>As design is always the result of human decision, the same issues also affect it as other actions based on the human decision [10]. In some cases, these human characteristics of designers such as beliefs, biases, interpretations, and experiences might provide innovative solutions, while in others for example in the case of novice designer’s the solutions might turn undesired [19]. Take, for example the previously mentioned bucket. One designer that has interpreted the main goal as carrying as much as water as possible might design a bigger bucket. While another designer might focus on improving the ergonomics of the buckets handle. As such, it is hard to say which one of the designers have provided a better solution. However, this demonstrates how the design process and its outcomes change drastically based on the actor.</p>
<p>In this article, the design process, according to the human-centered design approach is inspected more closely and the methods employed in it are evaluated against the outcomes. The focus of this article is on the inspection of how the designers themselves play a central role in HCD. We look at a body of literature consisting of articles that critique the effectiveness of HCD and especially its role in the development of innovation. I propose based on the reviewed research that the UCD process is capable of producing even radical innovation, but not without first developing methods to address the human characteristics of designers.</p>
<h3 id="human-centered-design--the-basis-for-incremental-innovation">Human-centered design — the basis for incremental innovation</h3>
<p>Human-centered design (HCD) is an approach concerned with incorporating the user’s perspective into the process [1,2,14]. According to the ISO-9241–210 standard it aims to make systems usable and useful through focusing on users, their need and requirements, as well as the properties of the users. End products and services that are produced through the HCD process quite often also employ interactiveness, meaning that product is designed, evaluated and redesigned until the product is deemed ready [3,14].</p>
<p>As previously stated one of the key components in HCD is the understanding the humans who are affected by the product or service, also know as the users. [5] The HCD process emphasizes conducting qualitative and contextual research for better understanding of the stakeholders at hand [5,12]. From this understanding, we can produce requirements for the product. A central method in gathering qualitative data is ethnographic field research, which is the process of studying groups and people through immersing the designer into the social setting [6,7]. Immersion is especially essential, as no deep understanding of the subject at can come from only observing the daily life of the subject from the outside. Actual knowledge comes from action and experience in the context [13]. As crucial as immersion, is the production of field notes, transformation of knowledge into written records observation and participation [6]. However, this also points in the design process where individual differences between designers create different interpretations of both the problems that people come across, as well as ways they interact [19]. This, in turn, leads to differences in what type of solutions are devised by the designers.</p>
<p>Where ethnographic research provides sparks for ideas and directions in which to take the product, are many of the HCD methods defining, correcting, and adjusting in their approach [11]. Methodologies such as rapid prototyping, testing, use observations, and interviews are just some of the several methods for finding out how the on-going design process can be elevated to better reflect the requirements of the users. Especially central, this is deemed in the areas of usability and accessibility, where the implicit image that the designer has of these properties is by itself insufficient [8]. This type of “poking” the design in the right direction through iterative changes in usability, functionality, and accessibility could also be seen as a method of hill-climbing that aims to reach the designs local optimum, however, for this work there has to already be a starting point [4]. The important thing to notice from all of this is the fact the there is significant difference in how much designers’ interpretations affect the design solution generation versus how much they affect the development of already devised solutions. In the latter, the interpretations of the designers have a less significant role, as the methods are more structured [8].</p>
<p>Donald Norman and Roberto Verganti have stated that HCD can provide incremental innovation in products, a statement that was based on the observations of the types of innovation in different cases [4]. Before diving into the meaning of incremental innovation, the concept of innovation will need to be concretized. Robert Reimann explains that innovation is often misunderstood and narrowly applied to advances in technology and production methods, but should also be extended to include a human-centered perspective of on empowering people to do more and do things easily [5].</p>
<p>How Norman and Verganti define incremental innovation is similar to Reimann’s narrower definition. According to Norman and Verganti, incremental innovation refers to all the small changes that end up improving its cost-effectiveness, performance, desirability, or result in a new release of the product [4]. Norman and Verganti also note that most successful product undergoes continual incremental innovation, a matter that can be addressed to businesses often trying to beat their competition through better products. Good examples of products that undergo incremental innovation are computers, which often go through periods in which no radical change happens in them but properties such as performance, size, and energy consumption are enhanced with every iteration.</p>
<p><img src="images/figure.png" alt=""></p>
<p>Figure 1. The diffusion of ideas, according to Everett Rogers. The blue line illustrates the successive groups of consumers adopting new technology. Yellow line illustrates the market share and its eventual saturation. Image CC-BY-AND Wikipedia.org</p>
<p>In addition to the earlier argument of incremental innovation being a driving force behind companies’ product differentiation process, it could also be argued that it is also behind the product adoption rate. Evidence to this can be formulated from the diffusion of innovations theory first introduced by Everett Rogers [9], which seeks to explain how and why new ideas and technology spread through cultures. The central concept of Rogers theory is that the potential users of a product can be categorized into 5 groups, whose sizes follow a normal distribution. Illustration of this can be found in figure one.</p>
<p>Rogers theory can be used to argue that HCD plays a central role in grounding innovation. The earlier mentioned viewpoint of HCD as a hill-climbing strategy is in line with this, as ideally, innovations’ should transition towards the goal of being more accepted by users [4]. HCD provides means for this through elevation of such properties as usability, accessibility, aesthetics, and experience. However, this notation has led some people to state that HCD is not capable of giving birth to radical innovation, as it focuses on incremental change in products. The next chapter will inspect this statement more closely as well as provide a counter-argument.</p>
<h3 id="radical-innovation--where-designers-flavor-comes-in-toplay">Radical innovation — where designer's flavor comes in to&nbsp;play</h3>
<p>Today the concept of HCD is entirely accepted by practitioners [5.] However, not without critique [4,5,12]. There have been doubts that if HCD process really helps in understanding users, is it possible that it’s obscuring designers’ view of possible solutions and if the emergence of radical innovation is possible through HCD as stated earlier [4,12].</p>
<p>At this point, it’s time to refine the concept of designers’ interpretations or “designer flavor” to reflect on the arguments of radical and incremental innovation development. Although novel by name, designer flavor, or the more familiar individuality is an old subject and topic of research in many research fields such as philosophy, psychology, economics, etc. When I talk about designer flavor, I’m are talking about the central properties of the human that create the basis for individuality, such as beliefs and emotions that in general can be seen as different mental representations. Our interaction with the surrounding world is based on our representations [11,14,16]. Therefore an argument that design process always is always a reflection of the designer can be made.</p>
<p>Bardzell provides an additional view of the previously stated and argues that the traditional approach of using the truth in the world is insufficient in some areas of HCD [14]. For example, user experience design (UXD) although extensively used and almost a buzzword, has a different definition and scope depending on whom the question is asked [14, 16]. According to Bardzell UX is one the areas of HCD that cannot be addressed by representationalist approach, which means that the correct knowledge is the product of representation (e.g., an idea or understanding) and given external reality. So, for example, the knowledge of how experiences are designed is “not out there” or there doesn’t exist a way to connect the reality and our mental representations [14]. Hassenzahl, who argues that the end products of design do not necessarily reflect upon the underlying needs, provides a similar argument. Experiences have to be designed from the point of understanding why people engage with such experiences, and as we are unable to understand fully how other people experience things [17], we have to someway incorporate our own experience into the process. As such, the designer plays a central role in the HCD process in incorporating these properties to the design, through his understanding of them.</p>
<p>As stated earlier Norman and Verganti argue that HCD in insufficient in producing radical innovation. According to them, radical innovation is born from either change in the meaning of the object or from the development of new enabling technology [4]. They continue that no evidence of radical innovation was found from the products that resulted from the HCD process and concluding that design research and formal analysis of needs is unnecessary for giving birth to radical innovation. The statement can be seen to hold the truth in the case of technological advances. The example used by Norman and Verganti comes from the world of video games. They provide convincing evidence of how the advancement of technology-enabled radical innovation that was sufficiently powerful in enabling the production of an entirely new collection of video games.</p>
<p>Norman and Verganti’s example of a change in meaning reviews how Nintendo changed the video game industry by changing the meaning of video games from a product targeted to niche segments to a product that meant meaningful experiences for everyone [4]. However, it could be suspected that this change in meaning ignited from the efforts of the designers at Nintendo to make video games more accessible by different types of people. This, in turn, can be simplified to consist of two parts:</p>
<ol>
<li>Understanding who are the users that cannot be reached by merely producing games with better graphics but also understanding why these users cannot be achieved through this method.</li>
<li>Innovating a solution to bridge this cap between video games and a broader audience.</li>
</ol>
<p>The first part is a direct consequence of understanding the people, which is the product of design research, and Norman and Verganti note that in this way HCD can provide a base for sparking innovation but it insufficient to do so by itself.</p>
<p>I now argue that although Norman and Verganti’s evidence holds in case that HCD is insufficient to produce radical innovation by itself, it is with the addition of designer's own interpretations fundamental to sparking radical innovation. It is the combination of these two that leads to employing a different design space that allows the development of radical innovation. Norman and Verganti’s idea of modifying the HCD process to require simultaneous development of multiple ideas and prototypes is not enough to spark radical innovation. The design purpose must be selected by the designer, not by the beneficiaries and be integrated continuously [12]. According to Sas, Cockton and Reimann and the problem with HCD is not that is doesn’t create radical innovation, as it was never meant do that. The problem with HCD is that it's not efficiently used to spark this innovation and cultivate it after the ignition [5,12,18].</p>
<p>As a final point I want to bring in the aspect of co-design to focus on the second part of the presented development of video games. As stated earlier, there are parts of the design where the truth in the world is not sufficient, such as experience design [14]. In these cases, empathy is central to the design process [18]. In co-design, this level of empathy can be elevated. And while I have argued that radical innovation can be sparked from the combination understanding the user and the designers’ interpretations of the world, there is still a possibility that no innovation is born and even some cases we end up with products that do not truly reflect the user's needs. Co-design could be seen as a solution to this as it has the potential for to provide better understanding of the users, help in making sense of things, and provide starting points radical innovation [18].</p>
<h3 id="conclusion">Conclusion</h3>
<p>This article has reflected on the role of the designer in the HCD and reflected on what is the role of the designer’s interpretation to the process. The focus has been narrowed down to inspection of innovation, making use of the concepts of incremental and radical innovation. I have proposed that designers’ interpretations of the world combined with human-centered design, plays a role in sparking radical innovation. Due to the scope of this article, the subject has been only vaguely introduced and weight has been shifted to inspecting the concept in relation to theories of innovation development in HCD. However, the subject of designers’ interpretations role in HCD is interesting and should be examined on its own, in relation to several other factors of design.</p>
<p>As a conclusion, human-centered design is both a process and approach to how that should incorporate the user throughout the whole design process. However, simply involving user as a mean of incrementally improving the design is not enough. For establishing radical innovation in design, there has to be a way of incorporating knowledge of the user that cannot be found by purely objective methods. How designers interpret the surrounding world provides a starting point for this, but actual user involving interpretation can only be found by co-designing approaches.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Throttling and memoizing App Store scraper calls]]></title>
            <link>https://perttu.dev/articles/throttling-and-memoing-app-store-scraping</link>
            <guid isPermaLink="false">https://perttu.dev/articles/throttling-and-memoing-app-store-scraping</guid>
            <pubDate>Thu, 04 Dec 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<h2 id="what-is-perttuapp-store-scraper">What is <code>@perttu/app-store-scraper</code></h2>
<p>I recently published <code>@perttu/app-store-scraper</code>, a TypeScript rebuild of the <a href="https://github.com/facundolaano">app-store-scraper by facundolaano</a> (it's a really good package by the way). I wanted to keep all the same features, just add TypeScript support improve some of the scraping after Apple updated their App Store website.</p>
<p>The package gives you the following features:</p>
<ul>
<li><code>app()</code> - Get app details</li>
<li><code>search()</code> - Search for apps</li>
<li><code>list()</code> - Get curated lists</li>
<li><code>developer()</code> - Get developer apps</li>
<li><code>reviews()</code> - Get user reviews</li>
<li><code>ratings()</code> - Get rating histogram</li>
<li><code>similar()</code> - Get similar apps</li>
<li><code>suggest()</code> - Get search suggestions</li>
<li><code>privacy()</code> - Get privacy details</li>
<li><code>versionHistory()</code> - Get version history</li>
</ul>
<h3 id="play-around-with-the-package">Play around with the package</h3>
<p>To test them out I built <a href="http://scrapers.perttu.dev/">this little website</a> where you can test all the APIs. All the data it returns comes from the scraping API.</p>
<p>One of the things you will run into when using this package is that <a href="https://performance-partners.apple.com/search-api">the App Store API is rate limited</a></p>
<blockquote>
<p>The Search API is limited to approximately 20 calls per minute (subject to change). If you require heavier usage, we suggest you consider using our Enterprise Partner Feed (EPF). For more information, visit the EPF documentation page.</p>
</blockquote>
<p>Simplest fix to this this is using both memoization and throttling. I didn't want to add these features to the package itself, because I wanted to keep it as minimal as possible. Show here's instead a guide on how to implement them yourself.</p>
<h2 id="throttling-and-memoizing-app-store-scraper">Throttling and memoizing app-store-scraper</h2>
<p>The easiest way to implement throttling and memoization is to use the <code>p-memoize</code> and <code>p-throttle</code> packages. Start by installing them:</p>
<pre class="language-bash"><code class="language-bash">bun <span class="token function">install</span> p-memoize p-throttle
</code></pre>
<h3 id="memoization">Memoization</h3>
<p>Then when using the package, you can wrap the functions with the memoize:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> app<span class="token punctuation">,</span> search <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'@perttu/app-store-scraper'</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> memoize <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'p-memoize'</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> cachedApp <span class="token operator">=</span> <span class="token function">memoize</span><span class="token punctuation">(</span>app<span class="token punctuation">,</span> <span class="token punctuation">{</span> maxAge<span class="token operator">:</span> <span class="token number">600000</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> cachedSearch <span class="token operator">=</span> <span class="token function">memoize</span><span class="token punctuation">(</span>search<span class="token punctuation">,</span> <span class="token punctuation">{</span> maxAge<span class="token operator">:</span> <span class="token number">600000</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>Now when you use the package, you can use the cached functions instead of the original ones:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword">const</span> appData <span class="token operator">=</span> <span class="token keyword control-flow">await</span> <span class="token function">cachedApp</span><span class="token punctuation">(</span><span class="token punctuation">{</span> id<span class="token operator">:</span> <span class="token number">553834731</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> results <span class="token operator">=</span> <span class="token keyword control-flow">await</span> <span class="token function">cachedSearch</span><span class="token punctuation">(</span><span class="token punctuation">{</span> term<span class="token operator">:</span> <span class="token string">'minecraft'</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>First time calling the function will be slower, because it will fetch the data from the API. But subsequent calls will be much faster, because the data will be cached. You can also specify the max age of the cache, so that the data will be refreshed after a certain time.</p>
<h3 id="throttling">Throttling</h3>
<p>With throttling you can limit the number of calls to the API within a certain time window. You're forced to do this because Apple's rate limits. Throttling only really comes to action when you're scraping over 20 apps, so use if for example when scraping a list of apps. This is what I did for <a href="apps.shipaton.com">Shipaton Apps Showcase</a>. Here's how to throttle the app function for with list of 100 apps:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> throttle <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'p-throttle'</span><span class="token punctuation">;</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> app <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'@perttu/app-store-scraper'</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> throttledApp <span class="token operator">=</span> <span class="token function">throttle</span><span class="token punctuation">(</span>app<span class="token punctuation">,</span> <span class="token punctuation">{</span> limit<span class="token operator">:</span> <span class="token number">20</span><span class="token punctuation">,</span> interval<span class="token operator">:</span> <span class="token number">60000</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> apps <span class="token operator">=</span> <span class="token keyword control-flow">await</span> <span class="token known-class-name class-name">Promise</span><span class="token punctuation">.</span><span class="token method function property-access">all</span><span class="token punctuation">(</span><span class="token known-class-name class-name">Array</span><span class="token punctuation">.</span><span class="token keyword module">from</span><span class="token punctuation">(</span><span class="token punctuation">{</span> length<span class="token operator">:</span> <span class="token number">100</span> <span class="token punctuation">}</span><span class="token punctuation">,</span> <span class="token keyword">async</span> <span class="token punctuation">(</span>_<span class="token punctuation">,</span> index<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token keyword control-flow">await</span> <span class="token function">throttledApp</span><span class="token punctuation">(</span><span class="token punctuation">{</span> id<span class="token operator">:</span> index <span class="token operator">+</span> <span class="token number">1</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><span class="token punctuation">;</span>
</code></pre>
<p>This will call the app function 100 times, but only 20 at a time, because of the throttling. You can also specify the interval, so that the calls are spaced out over a certain time.</p>
<p>That is pretty much it. If you have any questions, shoot me a message on <a href="https://twitter.com/plahteenlahti">Twitter</a> or <a href="https://linkedin.com/in/plahteenlahti">LinkedIn</a>.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Using styled-components with TypeScript]]></title>
            <link>https://perttu.dev/articles/tips-for-using-typescript-with-styled-components</link>
            <guid isPermaLink="false">https://perttu.dev/articles/tips-for-using-typescript-with-styled-components</guid>
            <pubDate>Thu, 18 Jun 2020 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>Using <a href="https://styled-components.com/">Styled-components</a> with React and React Native is great. Where it really shines in my opinion is when you use it with TypeScript and VS Code, getting code suggestions and errors when you write something wrong. In this article, we are going to take a look at how to use TypeScript with styled-components for better developer experience:</p>
<ol>
<li>Styled-components with VS Code and types for styled-components</li>
<li>Theme variable suggestions using declaration merging</li>
<li>Type checking for component props</li>
</ol>
<h2 id="styled-components-with-vs-code">Styled-components With VS Code</h2>
<p>As a prerequisite, it is crucial to mention the <a href="https://marketplace.visualstudio.com/items?itemName=styled-components.vscode-styled-components">vscode-styled-components plugin for VS Code</a>. This plugin vastly improves the writing of styled-components with syntax highlighting, error reporting, and IntelliSense. When combined with TypeScript and styled-components, the developer experience is truly enhanced.</p>
<p>Styled Components doesn’t come with types so we need to install them either running:</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">npm</span> i --save-dev @types/styled-components
</code></pre>
<p>or alternatively</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">yarn</span> <span class="token function">add</span> @types/styled-components --dev
</code></pre>
<h2 id="theme-variable-suggestions-using-declaration-merging">Theme variable suggestions using declaration merging</h2>
<p>Wouldn’t it be cool if you get type-checking for the themes you’ve created, as well as auto-complete for the theme variables? So that when you’re writing something like this:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token comment">// The commonly seen way of accessing them</span>
<span class="token keyword">const</span> <span class="token maybe-class-name">TextOne</span> <span class="token operator">=</span> styled<span class="token punctuation">.</span><span class="token property-access"><span class="token maybe-class-name">Text</span></span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token css language-css">
  <span class="token property">color</span><span class="token punctuation">:</span> <span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token punctuation">(</span><span class="token parameter">props</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> props<span class="token punctuation">.</span><span class="token property-access">theme</span><span class="token punctuation">.</span><span class="token property-access">primaryColor</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token punctuation">;</span>
</span><span class="token template-punctuation string">`</span></span>

<span class="token comment">// Or by using object desctructuring for cleaner code</span>
<span class="token keyword">const</span> <span class="token maybe-class-name">TextSecond</span> <span class="token operator">=</span> styled<span class="token punctuation">.</span><span class="token property-access"><span class="token maybe-class-name">Text</span></span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token css language-css">
  <span class="token property">color</span><span class="token punctuation">:</span> <span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token punctuation">(</span><span class="token parameter"><span class="token punctuation">{</span> theme <span class="token punctuation">}</span></span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> theme<span class="token punctuation">.</span><span class="token property-access">primaryColor</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token punctuation">;</span>
</span><span class="token template-punctuation string">`</span></span>
</code></pre>
<p>Then you wouldn’t have to remember if whether the primary color was written as <code>primaryColor</code> or <code>primary_color.</code> Instead, your IDE tells you what theme variables stored inside the theme object you’ve created.</p>
<p>Well good news, you can easily achieve this by using the Typescript interface merging to override the default theme that comes with styled-components (more info on how declaration merging works can be found <a href="https://www.typescriptlang.org/docs/handbook/declaration-merging.html">here</a>, and <a href="https://styled-components.com/docs/api#create-a-declarations-file">here</a>). Here’s how to achieve that:</p>
<ol>
<li>
<p>To access the theme in our app, we need to first set up ThemeProvider and then pass down our custom theme inside it.</p>
</li>
<li>
<p>Then we create the theme file, import the original styled-components module declaration and extend it using <a href="https://www.typescriptlang.org/docs/handbook/declaration-merging.html"><strong>declaration</strong> <strong>merging</strong></a>. We add our own theme variables like <code>primaryColor</code> to the <code>DefaultTheme</code> and tell our theme objects to use that interface.</p>
</li>
</ol>
<pre class="language-tsx"><code class="language-tsx"><span class="token comment">// App.tsx</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">React</span></span> <span class="token keyword module">from</span> <span class="token string">'react'</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> <span class="token maybe-class-name">ThemeProvider</span> <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'styled-components/native'</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">Navigation</span></span> <span class="token keyword module">from</span> <span class="token string">'/navigation'</span>
<span class="token keyword module">import</span> <span class="token imports"><span class="token punctuation">{</span> lightTheme <span class="token punctuation">}</span></span> <span class="token keyword module">from</span> <span class="token string">'/styles/theme'</span>

<span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">App</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">ThemeProvider</span></span> <span class="token attr-name">theme</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>lightTheme<span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Navigation</span></span> <span class="token punctuation">/&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">ThemeProvider</span></span><span class="token punctuation">&gt;</span></span>
  <span class="token punctuation">)</span>
  authors<span class="token operator">:</span> <span class="token punctuation">[</span><span class="token punctuation">{</span> name<span class="token operator">:</span> article<span class="token punctuation">.</span><span class="token property-access">author</span> <span class="token punctuation">}</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span>

<span class="token keyword module">export</span> <span class="token keyword module">default</span> <span class="token maybe-class-name">App</span>
</code></pre>
<p>We could do this in a separate styled-components.d.ts declaration file, but placing it inside the themes.ts file allows us to tweak the theme definition at the same when we add new variables.</p>
<p>With the declaration merging done using the theme variables suggestions in VS Code should look like this now:</p>
<img alt="Declaration merging example" loading="lazy" width="1400" height="271" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdeclaration-merging.91eade93.webp&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdeclaration-merging.91eade93.webp&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdeclaration-merging.91eade93.webp&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>No more guessing whether it was accentColor or colorAccent.</p>
<h2 id="type-checking-for-custom-props">Type-checking for custom Props</h2>
<p>Another typical case with styled-components is using the props of the styled component to alter the styling, e.g. creating a button container that has the prop “disabled” and then using it as the conditional for reducing opacity like this:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword module">import</span> <span class="token imports"><span class="token maybe-class-name">React</span></span> <span class="token keyword module">from</span> <span class="token string">'react'</span>
<span class="token keyword module">import</span> <span class="token imports">styled</span> <span class="token keyword module">from</span> <span class="token string">'styled-components/native'</span>

<span class="token keyword">type</span> <span class="token class-name"><span class="token maybe-class-name">Props</span></span> <span class="token operator">=</span> <span class="token punctuation">{</span>
  disabled<span class="token operator">:</span> <span class="token builtin">boolean</span>
<span class="token punctuation">}</span>

<span class="token keyword">const</span> <span class="token function-variable function"><span class="token maybe-class-name">StartButton</span></span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">{</span> disabled <span class="token punctuation">}</span><span class="token operator">:</span> <span class="token maybe-class-name">Props</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">{</span>
  <span class="token keyword control-flow">return</span> <span class="token punctuation">(</span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">Button</span></span> <span class="token attr-name">disabled</span><span class="token script language-javascript"><span class="token script-punctuation punctuation">=</span><span class="token punctuation">{</span>disabled<span class="token punctuation">}</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
      </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span><span class="token class-name">ButtonText</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">Start counter</span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">ButtonText</span></span><span class="token punctuation">&gt;</span></span><span class="token plain-text">
    </span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span><span class="token class-name">Button</span></span><span class="token punctuation">&gt;</span></span>
  <span class="token punctuation">)</span>
<span class="token punctuation">}</span>

<span class="token keyword">const</span> <span class="token maybe-class-name">Button</span> <span class="token operator">=</span> styled<span class="token punctuation">.</span><span class="token property-access"><span class="token maybe-class-name">TouchableOpacity</span></span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token css language-css">
  <span class="token property">opacity</span><span class="token punctuation">:</span> <span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token punctuation">(</span><span class="token parameter">props</span><span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span>props<span class="token punctuation">.</span><span class="token property-access">disabled</span> <span class="token operator">?</span> <span class="token number">0.5</span> <span class="token operator">:</span> <span class="token number">1</span><span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token punctuation">;</span>
</span><span class="token template-punctuation string">`</span></span>

<span class="token keyword">const</span> <span class="token maybe-class-name">ButtonText</span> <span class="token operator">=</span> styled<span class="token punctuation">.</span><span class="token property-access"><span class="token maybe-class-name">Text</span></span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token css language-css">
  <span class="token property">font-size</span><span class="token punctuation">:</span> <span class="token number">17</span><span class="token unit">px</span><span class="token punctuation">;</span>
</span><span class="token template-punctuation string">`</span></span>

<span class="token keyword module">export</span> <span class="token keyword module">default</span> <span class="token maybe-class-name">StartButton</span>
</code></pre>
<p>This works pretty well and when we type <code>props.something</code> VS Code should even show us an IntelliSense suggestion for the disabled prop, as long as we’ve installed the styled-components type definition. This is because those type definitions contain the type definition for <code>TouchableOpacity</code> with definition for the <code>disabled</code> prop. However, if were to define our own prop, something such as <code>primary</code> which would be of boolean value we would get a type error like the below:</p>
<img alt="screenshot of a code editor with JavaScript React code showing a styled-components error. The code defines a 'StartButton' functional component with props and renders a 'Button' component with 'primary' and 'disabled' props. An error tooltip indicates a type issue: 'No overload matches this call' and 'Property 'primary' does not exist on type...'. The 'Button' styled component is defined below with an opacity style based on props, and a 'ButtonText' styled component is defined with a font size. The error message suggests issues with the 'primary' property not being recognized in the styled component's type definition." loading="lazy" width="1400" height="441" decoding="async" data-nimg="1" class="rounded-xs" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ferror.e848cdd1.webp&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ferror.e848cdd1.webp&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ferror.e848cdd1.webp&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<p>To fix this we need to add a new type for our button which contains the primary prop and then tell styled-components to use it:</p>
<pre class="language-tsx"><code class="language-tsx"><span class="token keyword">type</span> <span class="token class-name"><span class="token maybe-class-name">ButtonProps</span></span> <span class="token operator">=</span> <span class="token punctuation">{</span>
  primary<span class="token operator">:</span> <span class="token builtin">boolean</span>
<span class="token punctuation">}</span>

<span class="token keyword">const</span> <span class="token maybe-class-name">Button</span> <span class="token operator">=</span> styled<span class="token punctuation">.</span><span class="token property-access"><span class="token maybe-class-name">TouchableOpacity</span></span><span class="token operator">&lt;</span><span class="token maybe-class-name">ButtonProps</span><span class="token operator">&gt;</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">
  opacity: </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token punctuation">(</span>props<span class="token punctuation">)</span> <span class="token arrow operator">=&gt;</span> <span class="token punctuation">(</span>props<span class="token punctuation">.</span><span class="token property-access">primary</span> <span class="token operator">?</span> <span class="token number">0.5</span> <span class="token operator">:</span> <span class="token number">1</span><span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">;
</span><span class="token template-punctuation string">`</span></span>
</code></pre>
<p>Now we should no longer get the error message unless we try to use the wrong value there. On top of that we also get autocomplete when using it to alter the styles:</p>
<img alt="Screenshot of a code editor displaying a JavaScript snippet using styled-components. There is a 'const Button' declaration for a styled TouchableOpacity with a ternary operation inside a template literal to determine its opacity. The operation reads: 'opacity: ${(props) => (props.pri... ? 0.5 : 1)};'. The autocomplete dropdown menu is suggesting 'primary' as a property, alongside other properties like 'pressRetentionOffset', 'onPressIn', and 'delayPressIn'. Below is a 'const ButtonText' declaration for a styled Text component with a 'font-size' of '17px'." loading="lazy" width="1400" height="273" decoding="async" data-nimg="1" class="rounded-xs" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fautocomplete.bbf22198.webp&amp;w=1920&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x, /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fautocomplete.bbf22198.webp&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 2x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fautocomplete.bbf22198.webp&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[How to use environment variables in Netlify functions]]></title>
            <link>https://perttu.dev/articles/using-environment-variables-in-netlify</link>
            <guid isPermaLink="false">https://perttu.dev/articles/using-environment-variables-in-netlify</guid>
            <pubDate>Fri, 21 Feb 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>When building things using Netlify Functions, there are times you need to access environment variables in your functions. These variables are available at runtime through <code>process.env</code> when using TypeScript or JavaScript:</p>
<pre class="language-typescript"><code class="language-typescript">process<span class="token punctuation">.</span><span class="token property-access">env</span><span class="token punctuation">.</span><span class="token constant">VARIABLE_NAME</span>
</code></pre>
<p>A cleaner way to access them is to destructure like this:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword">const</span> <span class="token punctuation">{</span> <span class="token constant">VARIABLE_ONE</span><span class="token punctuation">,</span> <span class="token constant">VARIABLE_TWO</span> <span class="token punctuation">}</span> <span class="token operator">=</span> process<span class="token punctuation">.</span><span class="token property-access">env</span>
</code></pre>
<p>Define these at the root level, and they'll be accessible throughout your function code.</p>
<h3 id="managing-variables">Managing Variables</h3>
<p>There are two main ways to manage environment variables in Netlify:</p>
<ol>
<li>
<p><strong>In your repository</strong> – Store them in a <code>.env</code> file and use a library like <code>dotenv</code> to load them. However the next way is better as it allows you keep secrets separate from your code.</p>
</li>
<li>
<p><strong>Through Netlify's UI</strong> – The better way is to set them in Netlify's dashboard under <strong>Site settings → Environment variables</strong>. This keeps them secure and available during builds and runtime.</p>
</li>
</ol>
<p>What you can't do is to use the variables defined in your netlify.toml file.</p>
<h3 id="local-development">Local Development</h3>
<p>If you need access to environment variables while developing locally, create a <code>.env</code> file at the root of your project:</p>
<pre class="language-bash"><code class="language-bash"><span class="token comment"># .env file</span>
<span class="token assign-left variable">API_KEY</span><span class="token operator">=</span>your-api-key
</code></pre>
<p>Then access them in your function:</p>
<pre class="language-typescript"><code class="language-typescript"><span class="token keyword">const</span> <span class="token punctuation">{</span> <span class="token constant">API_KEY</span> <span class="token punctuation">}</span> <span class="token operator">=</span> process<span class="token punctuation">.</span><span class="token property-access">env</span>
</code></pre>
<p>Run <code>netlify dev</code> to load them automatically.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[Vibe coding is prototyping for the masses]]></title>
            <link>https://perttu.dev/articles/vibe-coding-is-prototyping</link>
            <guid isPermaLink="false">https://perttu.dev/articles/vibe-coding-is-prototyping</guid>
            <pubDate>Sun, 27 Apr 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<blockquote>
<p>Goodbye Domain-Driven Development, Hello Domain-Embodied Development</p>
</blockquote>
<p>Vibe coding—prompting AI to generate code snippets—is getting increasing attention, not just among developers but also among people with little or no prior coding experience. Previously, building a solution required an engineer or possibly even a team. Now, it might only take someone with a problem and the willingness to prompt AI, with threats or prizes, into producing something useful.</p>
<p>As vibe coding spreads, it has taken over the developer memesphere. There are fundamentally two sides: one that claims it’s the worst thing ever and will lead to poorly written code and insecure services, and another that sees it as a valuable addition to the developer’s toolkit. These sides are almost completely opposite, so it’s no wonder people are struggling to make up their minds about it.</p>
<p>Understandably, people calling it the worst thing ever are basing it on the fact that most of the code that LLMs write is “intelligent” only in the sense that most of the code you can crawl through and use to train your models is not intelligent. There are patterns that are generally efficient, clean, and well-written, such as bubble sort. Asking for those from an LLM will produce good results as there’s enough material. Going beyond that will most definitely produce unwanted results. Anything beyond the general stuff doesn’t model well into statistical inference.</p>
<p>The other side of the conversation sees vibe coding as a tool where you prompt your way through menial tasks to produce results. Without demonizing any junior developers, commanding an LLM is akin to delegating tasks to a junior developer so you can focus on the big picture. The major difference is that junior developers usually don’t start hallucinating cross-framework solutions when they run out of skill; in some cases, they might even ask for help.</p>
<p>I personally fall into the latter camp, as I’ve found LLMs particularly good for things that require manual work. Recently, for example, I migrated an older React Native project that used styled-components for styling back to using StyleSheet (after a year of being a proponent of styled-components, I’ve changed my mind and consider them a misstep in styling evolution). Without vibe coding, I most likely would not have even revisited that project, seeing as that kind of change is essentially just reducing technical debt and therefore not something I would like to spend my spare time doing.</p>
<p>However, that is about using LLMs to improve existing projects, and people calling vibe coding a blasphemy are mostly referring to the cases where completely new projects are created using LLMs. I’ve done a few experiments in this space. LLMs certainly do tend to start pulling some very bad code when their context space no longer contains the original prompt you started working on. It’s not really that difficult to get Cursor to start writing React code in a React Native project, with divs and all, once it forgets you’re building a mobile app. Then again, that is something you, as a developer, can easily fix by making sure you’re adding the missing context when needed. You can also most likely fix most security problems by keeping that in mind.</p>
<p>Where I see the most potential for vibe coding is when it starts to get adopted by the masses; used by people who don’t work in technology and who have potentially never coded. Using vibe coding not to build the next SaaS startup but to solve their own problems in domains they’re experts in.</p>
<p>Domain being the keyword here. In traditional software development, the push for the last few decades has been for something called domain-driven development, a paradigm where domain experts are included in the development process in order to build products that actually solve a real problem. When I used to compete in hackathons, semi-professionally, it took about 10 hackathons, both won and lost, to figure out that including experts from another domain beyond coding was the key to consistently winning. Deviating from this model was always statistically less successful. Of the 70 hackathons I competed in, 40 were won using this approach.</p>
<h2 id="limits-of-user-centered-and-domain-driven-design">Limits of User-Centered and Domain-Driven Design</h2>
<p>Similarly, when I was studying design, involving domain experts in the design process was the paradigm we were hammered to follow. In design, a similar concept is called user-centered design, or even co-creation if users are actively doing some parts of the design. However, most user-centered design tends to be more user-inspired and centered around them. Similarly, domain-driven development is mostly inspired by domain experts; they are not the driving force behind it at all times. Arguing against this statement would require explaining how so many developers hate talking to users.</p>
<p>Perhaps we’ve reached the optimum state for domain-driven development and user-centered design. Already during my studies, quite a lot of discussion focused on how products created through user-centered design were often examples of incremental innovation, whereas radical innovation—meaning scale-changing innovation like the internet—was consistently produced by not involving users in the process at all. <a href="https://perttu.dev/articles/the-role-of-designers-subjective-interpretations-in-human-centered-design">I’ve written about this before if you’re interested.</a></p>
<h2 id="domain-embodied-development">Domain-Embodied Development</h2>
<p>If we reframe innovation as the serendipitous result of introducing a new pair of eyes into how we build the world, then it becomes clear that in the age of the internet, it is not the ones who built it this far, coders and designers, who will build the new businesses. It is the ones who’ve spent years studying and working in skill- and knowledge-based domains, such as teaching, healthcare, and manufacturing, that we should look to for building new businesses and innovations. The domain experts themselves will start building the software. Development would no longer be driven by the domain; it would be embodied by it.</p>
<p>I’ve had the luck to witness a win by this new domain-embodied development even before LLMs took over. In 2021, a friend of mine asked me to work on building the design system for a startup of theirs that had just been bought by Kahoot. This was just six months after they had founded the company. The product they worked on was very simple: an online whiteboard for teachers, something that COVID-19 suddenly made very crucial. Technology-wise, it was relatively simple to build, but apparently no one had cracked it in the same way before. The difference was that the founder who built this product was a teacher themselves; they knew from the start what features were crucial and could focus on building those. Without their vast domain knowledge of users and the market, this product most likely would not have made it, even though the market change caused by the pandemic helped.</p>
<p>When it comes to what tools will drive domain-embodied development, it’s hard to say, as they are still mostly developer-oriented. While tools such as Cursor enable developers to be increasingly efficient in building software in ways that feel close to magic, their fundamental context is still the IDE, something only developers really find inviting. Bolt.new and Lovable move away from the traditional development context, but they still end up spitting out code. Code that someone has to analyze, fix, improve, and maintain; all things which AIs handle rather shakily. Tools not being all there yet is, therefore, more a limitation of the performance of our current LLM models.</p>
<p>It is at that limitation where I personally think the current state of vibe coding is: in apps and websites that simulate the functionality and design of a final product, more commonly known as prototypes. Prototypes don’t need to be maintained; they don’t even need to be complete, or fully real, in order to validate ideas. Vibe coding, when done by developers, has, of course, the chance to cross the chasm from prototype to a real product, but for non-developers, that part is missing.</p>
<p>Despite being an AI skeptic, I predict that as models and the tools using them improve, we will start to see domain-embodied development become a crucial paradigm for building software, preferably by non-developers. Similarly, it will become easier to cross the prototype-to-product chasm without ever having to touch code.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[What I read in 2018]]></title>
            <link>https://perttu.dev/articles/what-i-read-in-2018</link>
            <guid isPermaLink="false">https://perttu.dev/articles/what-i-read-in-2018</guid>
            <pubDate>Fri, 31 May 2019 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p><strong>The Accidental Billionaires: The Founding of Facebook, a Tale of Sex, Money, Genius, and Betrayal by <a href="https://twitter.com/benmezrich">@benmezrich</a> ⭐️⭐️⭐️</strong> Reads more like a fiction book, but is still a decent look at the founding of Facebook. If you've seen The Social Network movie, you might not get much out of this.</p>
<p><strong>Venture Deals by <a href="https://twitter.com/bfeld">@bfeld</a></strong> ⭐️⭐️⭐️⭐⭐ Perhaps the best guide on how venture funding works and you should approach it if you're an entrepreneur. Helps you understand stuff like term sheets, convertible loans, and dilution. 5/5 will read again.</p>
<p><strong>Norse Mythology by <a href="https://twitter.com/neilhimself">@neilhimself</a></strong> ⭐️⭐️⭐️⭐⭐ Greek mythology used to be the thing I loved to read about, but after reading this books I'm definitely more in love with Norse mythology and the crazy stories about Loki, Thor, and Odin. Like reading campfire stories.</p>
<p><strong>Enkeleitä</strong> ja yksisarvisia <strong>– Startup-Suomen tarina by Tuomas Vimma</strong> ⭐️⭐️⭐️ Wouldn't call it a good book, but it's the only one there is about the birth of the Finnish startup ecosystem. Pair this with Kutsuvat sitä pöhinäksi.</p>
<p><strong>Vox by <a href="https://twitter.com/CVDalcher">@CVDalcher</a> ⭐️⭐️⭐️⭐️⭐️</strong> Set in an alternate future where women can only speak 100 words a day. The cognitive scientist &amp; scifi lover in me loved this book.</p>
<p><strong>How Not to Be Wrong: The Hidden Maths of Everyday Life by <a href="https://twitter.com/JSEllenberg">@JSEllenberg</a>⭐️⭐️⭐️⭐️</strong> Awesome book explaining the mathematics and statistics behind our everyday thinking, for example in what case you should take part in a lottery.</p>
<p><strong>Mere Christianity by C.S. Lewis ⭐️⭐️⭐️⭐️</strong> Former atheist writes about his understanding about Christianity. Worth reading even if you' re not a religious person, just to understand Christianity a little better</p>
<p><strong>It Doesn't Have to Be Crazy at Work by <a href="https://twitter.com/jasonfried">@jasonfried</a> ⭐️⭐️⭐️⭐️⭐️</strong> One of the most valuable books I've read this year. Strategic guide to building 'the calm company', an approach that reduces chaos, anxiety and stress at your workplace.</p>
<p><strong>Netflixed: The Epic Battle for America's Eyeballs by <a href="https://twitter.com/ginamkeating">@ginamkeating</a> ⭐️⭐️⭐️⭐️</strong> Captivating book on how Netflix was founded. Surprisingly my favorite parts were the once focusing on Blockbuster and its rivalry with Netflix.</p>
<p><strong>Lab Rats: How Silicon Valley Made Work Miserable for the Rest of Us by <a href="https://twitter.com/realdanlyons">@realdanlyons</a> ⭐️⭐️⭐️⭐⭐</strong> Last year I read Disrupted, which changed the way I looked at SV startups. Lab rats did the same and changed the way I think about lean startup, agile, and the modern way of working.</p>
<p><strong>I Hate the Internet by Jarett Kobek ⭐️⭐️⭐️⭐️</strong> Feels almost like reading a novel about Silicon Valley written by Kurt Vonnegut. Loved this book for its endless one-liners and how it shows no mercy for silicon valley.</p>
<p><strong>Lost and Founder: The Mostly Awful, Sometimes Awesome Truth about Building a Tech Startup by <a href="https://twitter.com/randfish">@randfish</a> ⭐⭐️⭐️⭐⭐</strong> The best book I've read this year, hands down. This book should read by everyone working on a startup or thinking about founding/joining one.</p>
<p><strong>Paranoid Optimist by <a href="https://twitter.com/rsiilasmaa">@rsiilasmaa</a> ⭐️⭐️⭐️⭐️</strong> Probably the best depiction of why Nokia lost the mobile phone war, and how it was able to redeem itself after it. Also an excellent look at how a well functioning board should work.</p>
<p><strong>Blackfish City by <a href="https://twitter.com/sentencebender">@sentencebender</a> ⭐️⭐️⭐️⭐️</strong> One of kind scifi novel with Orcamancers and floating cities. Starts slowly, but picks up speed after and in the end I was hoping it would have lasted a little longer. Also the best looking book cover ever.</p>
<p><strong>Springfield Confidential: Jokes, Secrets, and Outright Lies from a Lifetime Writing for The Simpsons by <a href="https://twitter.com/MikeReissWriter">@MikeReissWriter</a> ⭐️⭐️⭐️⭐️⭐️</strong> Not a huge Simpsons fan, yet enjoyed this book a lot. Really good (and sarcastic) look at how Simpsons is made, but also how comedy writing works.</p>
<p><strong>Brotopia: Breaking Up the Boys' Club of Silicon Valley by <a href="https://twitter.com/emilychangtv">@emilychangtv</a> ⭐️⭐️⭐️⭐️</strong> Book that should be read by everyone working in tech, to understand why it consists of mostly white males, how it got that way, and what should be done to fix it.</p>
<p><strong>The Launch Pad: Inside Y Combinator, Silicon Valley's Most Exclusive School for Startups by Randall E. Stross ⭐️⭐️⭐️⭐️</strong> Read this while I was applying to <a href="https://twitter.com/Ycombinator">@Ycombinator</a> and it was a really eye-opening on how the most prestigious accelerator in the world works.</p>
<p><strong>Crush It!: Why Now Is the Time to Cash In on Your Passion by <a href="https://twitter.com/garyvee">@garyvee</a> ⭐️⭐️⭐️</strong> I've never really understood the hype surrounding Gary Vaynerchuk or the so called struggle porn. However, this book did have its moments and did help me understand what <a href="https://twitter.com/garyvee">@garyvee</a> talks about better.</p>
<p><strong>Rework by <a href="https://twitter.com/jasonfried">@jasonfried</a> &amp; <a href="https://twitter.com/dhh">@dhh</a> ⭐️⭐️⭐️⭐⭐</strong> A book every founder should read at some point, just to understand that workaholism and long hours are not a sign of a good company nor a founder. You can read this book in an afternoon.</p>
<p><strong>Furiously Happy: A Funny Book About Horrible Things by <a href="https://twitter.com/TheBloggess">@TheBloggess</a> ⭐️⭐️⭐️⭐</strong> I actually picked up this book just because of its cover (you have to see it), but ended up loving everything in it. Humoristic take on subjects such as mental illness, depression, and anxiety.</p>
<p><strong>The Innovator's Dilemma: The Revolutionary Book that Will Change the Way You Do Business by <a href="https://twitter.com/claychristensen">@claychristensen</a> ⭐️⭐️⭐️⭐️</strong> If you want to understand innovation &amp; disruption and how underdog startups can win against older, bigger, and often better companies read this book.</p>
<p><strong>Contagious: Why Things Catch On by <a href="https://twitter.com/j1berger">@j1berger</a> ⭐️⭐️⭐️⭐</strong> Fun book full of ideas on how to things more interesting. Good book if you're interested in marketing and branding. Pair this with Made to Stick.</p>
<p><strong>Made to Stick: Why Some Ideas Survive and Others Die by Chip Heath, Dan Heath ⭐️⭐️⭐️⭐</strong> Guide to making ideas and things to stick. Changed the way I think about copywriting and pitching. Pair this with Contagious.</p>
<p><strong>Nudge: Improving Decisions About Health, Wealth, and Happiness by <a href="https://twitter.com/R_Thaler">@R_Thaler</a>, <a href="https://twitter.com/CassSunstein">@CassSunstein</a> ⭐️⭐️⭐️</strong> If you've read Thinking fast and slow, and like behavioral economic, you should read this book. Short book but filled with different cases of nudging.</p>
<p><strong>Open Innovation: The New Imperative for Creating And Profiting from Technology by Henry William Chesbrough ⭐️⭐️⭐️</strong> You should read this if you're working in a corporation and feel that you're not innovating enough. You should also read this if you're a technology startup.</p>
<p><strong>Dear Founder: Letters of Advice for Anyone Who Leads, Manages, or Wants to Start a Business by <a href="https://twitter.com/maynard">@maynard</a> ⭐️⭐️⭐️⭐️⭐️</strong> Most likely the best collection of advice every startup founder needs. Keep it on your desk and consult when you come across problems.</p>
<p><strong>Work for Money, Design for Love by <a href="https://twitter.com/DavidAirey">@DavidAirey</a> ⭐️⭐️⭐️⭐️</strong> Wish I had come across this book when I started my first business. It has all the answers to starting and running a successful design business.</p>
<p><strong>Traction: A Startup Guide to Getting Customers by <a href="https://twitter.com/yegg">@yegg</a> &amp; <a href="https://twitter.com/jwmares">@jwmares</a> ⭐️⭐️⭐️⭐️</strong> So happy that I came across this book. Filled with advice on how to get that much-needed traction for your startup and framework for implementing and testing different methods for gaining traction.</p>
<p><strong>Solving Product Design Exercises: Questions &amp; Answers by <a href="https://twitter.com/hvost">@hvost</a> ⭐️⭐️⭐️⭐</strong> Although more oriented towards applying to your first design job, this book also excellent for designing your hiring pipeline so that you hire the best designers for your company.</p>
<p><strong>Why We Sleep: Unlocking the Power of Sleep and Dreams by <a href="https://twitter.com/sleepdiplomat">@sleepdiplomat</a>⭐️⭐️⭐️⭐⭐</strong> Read this book, it will change your life. Or if it doesn't do, it will at least change how you look at sleep.</p>
<p><strong>Experience on Demand: What Virtual Reality Is, How It Works, and What It Can Do by Jeremy Bailenson ⭐️⭐️⭐️⭐</strong> If you want to understand the capabilities of VR, and how it has been used e.g. to cure phobias read this book. No unnecessary hype, just the facts about VR.</p>
<p><strong>The Culture Code: The Secrets of Highly Successful Groups by <a href="https://twitter.com/DanielCoyle">@DanielCoyle</a>⭐️⭐️⭐️⭐⭐</strong> A look at some of the best organizations and what makes them so successful. Helpful for understanding your own company's culture and how to make it better.</p>
<p><strong>Kutsuvat sitä pöhinäksi – Tositarinoita kasvuyrittäjyydestä by <a href="https://twitter.com/KHelaniemi">@KHelaniemi</a><a href="https://twitter.com/annaleenakur">@annaleenakur</a> <a href="https://twitter.com/VenlaVakevainen">@VenlaVakevainen</a> ⭐️⭐️⭐️</strong> Pair this with "Enkeleitä ja yksisarvisia". Demystifies startup buzz. Wish there had been something like this when I was in high school.</p>
<p><strong>Never Eat Alone: And Other Secrets to Success, One Relationship at a Time by <a href="https://twitter.com/ferrazzi">@ferrazzi</a> ⭐️⭐️⭐️</strong> Kinda like Dale Carnegie's How to make Friends but for business. Concrete steps to getting started in networking and making better connections.</p>
<p><strong>Vicious by <a href="https://twitter.com/veschwab">@veschwab</a> ⭐️⭐️⭐️⭐️</strong> Revenge story with psychopaths and superpowers - what's not to like?</p>
<p><strong>Steal Like an Artist: 10 Things Nobody Told You About Being Creative by <a href="https://twitter.com/austinkleon">@austinkleon</a> ⭐️⭐️⭐️⭐⭐</strong> Like an Aspirin for inspiration and motivation, or lack of those. You can finish it in one sitting, but it's still able to challenge you to be a better designer.</p>
<p><strong>Autonomous by <a href="https://twitter.com/Annaleen">@Annaleen</a> ⭐️⭐️⭐️⭐</strong> Hard to explain in a tweet but let's try. Scifi book about biotech, patents, and artificial intelligence, with a pharmaceutical pirate bringing cheap drugs to poor people.</p>
<p><strong>Ask an Astronaut: My Guide to Life in Space by <a href="https://twitter.com/astro_timpeake">@astro_timpeake</a> ⭐️⭐️⭐️⭐️</strong> Ever wondered how astronauts go to the toilet in space? The answer to this and many other questions can be found in this wonderful book. Super fun to read.</p>
<p><strong>Creative Confidence: Unleashing the Creative Potential Within Us All by <a href="https://twitter.com/kelleybros">@kelleybros</a>⭐️⭐️⭐️⭐️</strong> A book about how everyone can be creative. Highly informative yet really fun to read. You should definitely read this if you don't see yourself as a creative person.</p>
<p><strong>Startup Studio Playbook by <a href="https://twitter.com/aszig">@aszig</a> ⭐️⭐️⭐️⭐️</strong> Wish this book was longer, because the subject is so interesting. I think this is the only book about this subject? Gives a pretty good look at the current startup studios and how they work.</p>
<p><strong>Bad Blood: Secrets and Lies in a Silicon Valley Startup by <a href="https://twitter.com/JohnCarreyrou">@JohnCarreyrou</a>⭐️⭐️⭐️⭐⭐</strong> This book is really hard to put down once you start reading it. The story of Theranos and its founder Elisabeth Holmes is so crazy.</p>
<p><strong>Bank 3.0 - Why Banking is No Longer Somewhere You Go, But Something You Do by <a href="https://twitter.com/BrettKing">@BrettKing</a> ⭐️⭐️⭐️</strong> There's already a version 4.0 out of this book, which is probably more timely. Still a pretty good read, and as a former analyst at a bank, this book had me nodding at several points.</p>
<p><strong>The Everything Store: Jeff Bezos and the Age of Amazon by <a href="https://twitter.com/BradStone">@BradStone</a> ⭐️⭐️⭐️⭐</strong> Captivating story of Amazon and Jeff Bezos. Found this book really valuable because of dives into the culture and ways of working at Amazon.</p>
<p><strong>Think and Grow Rich by Napoleon Hill ⭐️⭐️⭐️⭐</strong> Classic self-improvement book. The age of the book shows a little, and the book ends up being a weird combination of both relevant advice and some really funny pseudoscience stuff.</p>
<p><strong>The Intelligent Investor by Benjamin Graham ⭐️⭐️⭐️⭐⭐</strong> The classic book on value investing that everyone interested in investing should read just to understand the basis of value investing. Book is better towards the end.</p>
<p><strong>Hacking Growth: How Today's Fastest-Growing Companies Drive Breakout Success by <a href="https://twitter.com/SeanEllis">@SeanEllis</a> ⭐️⭐️⭐️⭐</strong> Still pretty much the best book about growth hacking. I think every business owner should read this book at least once, even if they don't see growth as an important factor.</p>
<p><strong>Creativity, Inc.: Overcoming the Unseen Forces That Stand in the Way of True Inspiration by Ed Catmull ⭐️⭐️⭐️⭐⭐</strong> Really great book about inspiration, Pixar and what makes it different. Really useful book for building a more creativity driven business.</p>
<p><strong>Masters of Doom: How Two Guys Created an Empire and Transformed Pop Culture by <a href="https://twitter.com/davidkushner">@davidkushner</a> ⭐️⭐️⭐️⭐⭐</strong> Damn this book was good. Tells a story id Software and the masterminds behind it and games like Quake and Doom. Most likely going to read it again.</p>
<p><strong>The Quick and Easy Way to Effective Speaking by Dale Carnegie ⭐️⭐️⭐️</strong> The title pretty much says it all. Useful book to look at everytime you're going to do a new type of speaking gig. If you like How to Win Friends and Influence People you will probably find this useful as well.</p>
<p><strong>The View from the Cheap Seats: Selected Nonfiction by <a href="https://twitter.com/neilhimself">@neilhimself</a> ⭐️⭐️⭐️⭐</strong> Collection of nonfiction writing by Neil Gaiman. Personal favorites are the ones that talk about libraries. Really useful for structuring your own talks and writings as well.</p>
<p><strong>How to Turn Down a Billion Dollars: The Snapchat Story by Billy Gallagher ⭐️⭐️⭐️⭐⭐</strong> Really good for understanding what made Snapchat so successful. Valuable also understanding how Snapchat actually got started and who is actually the original inventor of Snapchat.</p>
<p><strong>The Startup Way: Making Entrepreneurship a Fundamental Discipline of Every Enterprise by <a href="https://twitter.com/ericries">@ericries</a> ⭐️⭐️⭐️⭐</strong> If <strong><em>Lean Startup</em></strong> is must read for every startup founder, then this a for the people working in large corporations. I would pair this with Open Innovation by Chesbrough.</p>
<p><strong>Man's Search for Meaning by Viktor E. Frankl ⭐️⭐️⭐️⭐</strong> This book played a big role in finding my way through some hard times last year. Helped me to understand that you can't avoid suffering, but you can choose how to deal with it.</p>
<p><strong>The $100 Startup: Reinvent the Way You Make a Living, Do What You Love, and Create a New Future by <a href="https://twitter.com/chrisguillebeau">@chrisguillebeau</a> ⭐️⭐️⭐️⭐</strong> Valuable lessons in turning your ideas into a viable business. Lowers barriers to entrepreneurship by focusing on small businesses.</p>
<p><strong>The Devil in the White City: Murder, Magic, and Madness at the Fair That Changed America by <a href="https://twitter.com/exlarson">@exlarson</a> ⭐️⭐️⭐️⭐</strong> Tells the story of H.H. Holmes, one of the most terrifying serial killers in America. Read it as a physical book, the audiobook was harder to follow.</p>
<p><strong>The Internet of Money by <a href="https://twitter.com/aantonop">@aantonop</a> ⭐️⭐️⭐️</strong> A good introduction to why cryptocurrencies that explains what makes this technology so significant to not just finance but also other things.</p>
<p><strong>How to Shoot a Feature Film for Under $10,000: And Not Go To Jail by Bret Stern ⭐️⭐️⭐️⭐</strong> Liked the style and subject of this book. The book is quite funny, although slightly bro-ey. The age of the book is starting to show a little.</p>
<p><strong>Flash Boys: A Wall Street Revolt by Michael Lewis ⭐️⭐️⭐️⭐</strong> Michael Lewis never let's down. Great book about high-frequency trading. You would expect a book about stock trading to be boring but this is far from it.</p>
<p><strong>Freakonomics: A Rogue Economist Explores the Hidden Side of Everything by Steven D. Levitt, Stephen J. Dubner ⭐️⭐️⭐️⭐</strong> Even if you don't like economics you should consider reading this book. If for nothing else, then just for the conversations starters you can get out of it.</p>
<p><strong>Good Strategy/Bad Strategy: The difference and why it matters by Richard P. Rumelt ⭐️⭐️⭐️⭐⭐️</strong> Great book about strategy, without all the unnecessary bullshit. Read this book and learn the real meaning of word strategy and also how to use it.</p>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[What I’m building for Shipaton 2025]]></title>
            <link>https://perttu.dev/articles/what-im-building-for-shipaton-2025</link>
            <guid isPermaLink="false">https://perttu.dev/articles/what-im-building-for-shipaton-2025</guid>
            <pubDate>Thu, 07 Aug 2025 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<p>So I’m also taking part in Shipaton this year. If you’re not familiar with Shipaton, it’s a yearly hackathon by RevenueCat where the general goal is to build a mobile app, launch it for iOS or Android, and grow it. This year’s Shipaton takes place from August to September (I’m writing this from the kickoff we had for it in Tokyo). Because I’m a RevenueCat employee, I’m not eligible to actually compete for the main prize, but I’m still taking part.</p>
<p>So what will I be building?</p>
<p>Well — three apps, actually.</p>
<h2 id="saunuas">Saunuas</h2>
<p>The first one is a completely new one, called Saunuas. I’ve had this idea for a while: to build a fitness app centered around saunas. Core functionality will be to track sauna sessions—how long you were in a sauna, what your vitals were during that time, and metadata about the sauna such as humidity, temperature, etc.</p>
<p>A key part of the app will be an Apple Watch app. I’ve done a lot of personal testing on how well Apple Watch handles being in a sauna, and so far I’ve only managed to make it shut down due to overheating once—and that was by basically holding my hand in the hottest possible place for 15 minutes. In most cases, you’re not going to behave like that in a sauna, so it should work decently enough.</p>
<p>I’ll be building this in SwiftUI since it’s been some time since I’ve touched it. First features will be:</p>
<ul>
<li>Displaying data from sauna sessions</li>
<li>Storing sauna sessions as workouts using WorkoutKit and HealthKit</li>
<li>A simple list view of sessions</li>
</ul>
<p>I have many ideas on how to expand the app from here. Building a gorgeous-looking UI will be the main focus (I’m thinking of something with a dark theme, with warm reddish colors—kinda like in a sauna).</p>
<h2 id="contentfully">Contentfully</h2>
<p>I built an unreleased app for managing Contentful spaces a few years ago. I recently opened it up again, and it has surprisingly nice architecture and a lot of features—but releasing it would require a lot of work. I still want to get it out, so I’ve started rebuilding it from scratch with an updated architecture that allows releasing new features more incrementally.</p>
<p>I’ve already made some solid progress and plan to release the MVP version next week. The first version will just allow you to view assets, models, content, and other information, but the plan is to bring editing functionality in future versions. All done in an incremental way.</p>
<p>Key features will also include notifications, because that’s something I feel is really missing from Contentful—at least in a way that’s easy for non-technical people to set up for their projects.</p>
<img alt="Contentfully" loading="lazy" width="3840" height="2160" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcontentfully.1e6b6008.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcontentfully.1e6b6008.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">
<h2 id="netlifyi">Netli.fyi</h2>
<p><a href="https://plahteenlahti.medium.com/i-built-a-react-native-app-to-manage-netlify-hosted-sites-755377b1b1a6">I originally built Netli.fyi in 2021</a> to manage Netlify sites on the go. It was in the App Store for a bit over a year, I think. Most of its life it was completely free, and I eventually pulled the plug on the app because fixing bugs and supporting users ended up being way too much work—especially since I was working a very demanding job at the time.</p>
<p>Now I’m bringing it back. Slowly, over the years, I’ve built a few different versions of it, and I think I’m going to use one of the unreleased versions as a base (image below shows just how many different versions I have on my phone).</p>
<p>It’s still going to be a React Native app, with a fully open source codebase. You’ll still be able to manage your Netlify sites, deploy changes, and all the bells and whistles. I’ll also be adding notifications and Live Activities this time to make it so that you can monitor your site building in real time.</p>
<img alt="Netli.fyi" loading="lazy" width="3840" height="2160" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fnetlifyi.e5d3b07e.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fnetlifyi.e5d3b07e.png&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX">]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
        <item>
            <title><![CDATA[What I'm reading in 2026]]></title>
            <link>https://perttu.dev/articles/what-im-reading-in-2026</link>
            <guid isPermaLink="false">https://perttu.dev/articles/what-im-reading-in-2026</guid>
            <pubDate>Mon, 26 Jan 2026 00:00:00 GMT</pubDate>
            <content:encoded><![CDATA[<div class="not-prose rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-4 mb-8"><span class="text-amber-900 dark:text-amber-100 text-sm block"><p>This is an interactive article that gets updated throughout the year. Worth bookmarking and coming back to later.</p></span></div>
<h2 id="what-is-this-page">What is this page?</h2>
<p>I like reading, writing, and building things that are kinda crazy and dumb. This interactive blog post is an amalgation of all three of those: an interactive article that tracks what i'm reading and will be reading throughout 2026, with the aim of reaching my reading goal of 2 books a week (106 books in total, since technically this year has 53 weeks).</p>
<p>I used to do this with Goodreads, but I feel bad contributing to that app at this point because all of their product development has essentially stalled, and I've not come across a worthy substitute (a lot of apps have tried through). So why not build my own system that allows me to do few things, but do them well:</p>
<ul>
<li>Track what I've read</li>
<li>Add notes to the books so I always come back to see what I though of each book</li>
<li>Present these things in way that I find interesting, and that allows me to store them in my own corner of the internet</li>
</ul>
<p>So I built this page. Let me know what you think <a href="/about">(you can find how to reach me on the About page)</a></p>
<h2 id="reading-progress">Reading progress</h2>
<p>Below numbers are automatically updated as I update this blog post. I might add other metrics but at this point the most interesting thing for me is how many books I've read so far, am I in schedule, and how many am I projected to read. I threw in the total page count out of curiosity.</p>
<div class="not-prose"><div class="py-8 sm:py-12 mb-8"><div class="max-w-4xl mx-auto"><div class="grid grid-cols-3 gap-4"><div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4 sm:p-6 overflow-hidden"><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 dark:text-zinc-500 mb-2">Pages Read</p><p class="text-2xl sm:text-5xl font-light tracking-tight text-zinc-900 dark:text-zinc-100 truncate">7,813</p><p class="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 mt-1 truncate">372 avg per book</p></div><div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4 sm:p-6 overflow-hidden"><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 dark:text-zinc-500 mb-2">Pace</p><p class="text-2xl sm:text-5xl font-light tracking-tight text-zinc-900 dark:text-zinc-100 truncate">-15</p><p class="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 mt-1 truncate">books behind</p></div><div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4 sm:p-6 overflow-hidden"><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 dark:text-zinc-500 mb-2">Projected Total</p><p class="text-2xl sm:text-5xl font-light tracking-tight text-zinc-900 dark:text-zinc-100 truncate">62</p><p class="text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 mt-1 truncate">at current pace</p></div></div><div class="mt-6 sm:mt-8"><div class="relative"><div class="h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="absolute top-0 h-full w-0.5 bg-zinc-400 dark:bg-zinc-600 z-10" style="left:33.9622641509434%"></div><div class="h-full bg-zinc-900 dark:bg-zinc-100 transition-all duration-1000 ease-out" style="width:19.81132075471698%"></div></div><div class="flex justify-between mt-2 text-[10px] font-mono uppercase tracking-wider text-zinc-500"><span>21<!-- --> read</span><span>106<!-- --> goal</span></div></div></div></div></div></div>
<h3 id="reading-breakdown">Reading breakdown</h3>
<div class="not-prose mb-8"><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 mb-3">Author Gender</p><div class="h-3 flex overflow-hidden"><div class="bg-zinc-700 dark:bg-zinc-300 first:rounded-l-sm last:rounded-r-sm transition-all duration-500" style="width:60%"></div><div class="bg-amber-500 dark:bg-amber-400 first:rounded-l-sm last:rounded-r-sm transition-all duration-500" style="width:36%"></div><div class="bg-zinc-400 dark:bg-zinc-500 first:rounded-l-sm last:rounded-r-sm transition-all duration-500" style="width:4%"></div></div><div class="flex flex-wrap gap-x-5 gap-y-1 mt-3"><div class="flex items-center gap-2"><div class="w-2.5 h-2.5 rounded-full bg-zinc-700 dark:bg-zinc-300"></div><span class="text-xs text-zinc-600 dark:text-zinc-400">Male<!-- --> <span class="text-zinc-400 dark:text-zinc-500">15<!-- --> (<!-- -->60<!-- -->%)</span></span></div><div class="flex items-center gap-2"><div class="w-2.5 h-2.5 rounded-full bg-amber-500 dark:bg-amber-400"></div><span class="text-xs text-zinc-600 dark:text-zinc-400">Female<!-- --> <span class="text-zinc-400 dark:text-zinc-500">9<!-- --> (<!-- -->36<!-- -->%)</span></span></div><div class="flex items-center gap-2"><div class="w-2.5 h-2.5 rounded-full bg-zinc-400 dark:bg-zinc-500"></div><span class="text-xs text-zinc-600 dark:text-zinc-400">Multiple<!-- --> <span class="text-zinc-400 dark:text-zinc-500">1<!-- --> (<!-- -->4<!-- -->%)</span></span></div></div></div>
<div class="not-prose mb-8"><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 mb-3">Fiction vs Non-Fiction</p><div class="h-3 flex overflow-hidden"><div class="bg-zinc-700 dark:bg-zinc-300 rounded-l-sm transition-all duration-500" style="width:72%"></div><div class="bg-amber-500 dark:bg-amber-400 rounded-r-sm transition-all duration-500" style="width:28.000000000000004%"></div></div><div class="flex gap-x-5 mt-3 mb-8"><div class="flex items-center gap-2"><div class="w-2.5 h-2.5 rounded-full bg-zinc-700 dark:bg-zinc-300"></div><span class="text-xs text-zinc-600 dark:text-zinc-400">Fiction<!-- --> <span class="text-zinc-400 dark:text-zinc-500">18<!-- --> (<!-- -->72<!-- -->%)</span></span></div><div class="flex items-center gap-2"><div class="w-2.5 h-2.5 rounded-full bg-amber-500 dark:bg-amber-400"></div><span class="text-xs text-zinc-600 dark:text-zinc-400">Non-fiction<!-- --> <span class="text-zinc-400 dark:text-zinc-500">7<!-- --> (<!-- -->28<!-- -->%)</span></span></div></div><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 mb-3">By Genre</p><div class="space-y-2"><div class="flex items-center gap-3"><span class="text-xs text-zinc-600 dark:text-zinc-400 w-28 sm:w-36 shrink-0 truncate capitalize">literary fiction</span><div class="flex-1 h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full bg-zinc-700 dark:bg-zinc-300 transition-all duration-500" style="width:100%"></div></div><span class="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 w-6 text-right">7</span></div><div class="flex items-center gap-3"><span class="text-xs text-zinc-600 dark:text-zinc-400 w-28 sm:w-36 shrink-0 truncate capitalize">litrpg</span><div class="flex-1 h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full bg-zinc-700 dark:bg-zinc-300 transition-all duration-500" style="width:100%"></div></div><span class="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 w-6 text-right">7</span></div><div class="flex items-center gap-3"><span class="text-xs text-zinc-600 dark:text-zinc-400 w-28 sm:w-36 shrink-0 truncate capitalize">self-help</span><div class="flex-1 h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full bg-zinc-700 dark:bg-zinc-300 transition-all duration-500" style="width:28.57142857142857%"></div></div><span class="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 w-6 text-right">2</span></div><div class="flex items-center gap-3"><span class="text-xs text-zinc-600 dark:text-zinc-400 w-28 sm:w-36 shrink-0 truncate capitalize">mythology</span><div class="flex-1 h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full bg-zinc-700 dark:bg-zinc-300 transition-all duration-500" style="width:28.57142857142857%"></div></div><span class="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 w-6 text-right">2</span></div><div class="flex items-center gap-3"><span class="text-xs text-zinc-600 dark:text-zinc-400 w-28 sm:w-36 shrink-0 truncate capitalize">non-fiction</span><div class="flex-1 h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full bg-zinc-700 dark:bg-zinc-300 transition-all duration-500" style="width:28.57142857142857%"></div></div><span class="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 w-6 text-right">2</span></div><div class="flex items-center gap-3"><span class="text-xs text-zinc-600 dark:text-zinc-400 w-28 sm:w-36 shrink-0 truncate capitalize">horror</span><div class="flex-1 h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full bg-zinc-700 dark:bg-zinc-300 transition-all duration-500" style="width:28.57142857142857%"></div></div><span class="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 w-6 text-right">2</span></div><div class="flex items-center gap-3"><span class="text-xs text-zinc-600 dark:text-zinc-400 w-28 sm:w-36 shrink-0 truncate capitalize">science fiction</span><div class="flex-1 h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full bg-zinc-700 dark:bg-zinc-300 transition-all duration-500" style="width:14.285714285714285%"></div></div><span class="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 w-6 text-right">1</span></div><div class="flex items-center gap-3"><span class="text-xs text-zinc-600 dark:text-zinc-400 w-28 sm:w-36 shrink-0 truncate capitalize">writing craft</span><div class="flex-1 h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full bg-zinc-700 dark:bg-zinc-300 transition-all duration-500" style="width:14.285714285714285%"></div></div><span class="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 w-6 text-right">1</span></div><div class="flex items-center gap-3"><span class="text-xs text-zinc-600 dark:text-zinc-400 w-28 sm:w-36 shrink-0 truncate capitalize">fantasy</span><div class="flex-1 h-2 bg-zinc-100 dark:bg-zinc-800 overflow-hidden"><div class="h-full bg-zinc-700 dark:bg-zinc-300 transition-all duration-500" style="width:14.285714285714285%"></div></div><span class="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 w-6 text-right">1</span></div></div></div>
<div class="not-prose mb-8"><p class="text-[10px] sm:text-xs font-mono uppercase tracking-widest text-zinc-500 mb-3">Author Nationality</p><div class="border border-zinc-200 dark:border-zinc-800 overflow-hidden bg-zinc-50 dark:bg-zinc-900/50"><svg viewBox="0 0 800 380" class="rsm-svg " style="width:100%;height:auto"><g class="rsm-geographies "></g></svg></div><div class="flex flex-wrap gap-3 mt-4"><div class="flex items-center gap-2"><span class="text-xl leading-none" role="img" aria-label="United States">🇺🇸</span><div><span class="text-xs font-medium text-zinc-900 dark:text-zinc-100">United States</span><span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1.5">18<!-- --> <!-- -->books</span></div></div><div class="flex items-center gap-2"><span class="text-xl leading-none" role="img" aria-label="France">🇫🇷</span><div><span class="text-xs font-medium text-zinc-900 dark:text-zinc-100">France</span><span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1.5">1<!-- --> <!-- -->book</span></div></div><div class="flex items-center gap-2"><span class="text-xl leading-none" role="img" aria-label="Japan">🇯🇵</span><div><span class="text-xs font-medium text-zinc-900 dark:text-zinc-100">Japan</span><span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1.5">1<!-- --> <!-- -->book</span></div></div><div class="flex items-center gap-2"><span class="text-xl leading-none" role="img" aria-label="United Kingdom">🇬🇧</span><div><span class="text-xs font-medium text-zinc-900 dark:text-zinc-100">United Kingdom</span><span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1.5">1<!-- --> <!-- -->book</span></div></div><div class="flex items-center gap-2"><span class="text-xl leading-none" role="img" aria-label="Argentina">🇦🇷</span><div><span class="text-xs font-medium text-zinc-900 dark:text-zinc-100">Argentina</span><span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1.5">1<!-- --> <!-- -->book</span></div></div><div class="flex items-center gap-2"><span class="text-xl leading-none" role="img" aria-label="Australia">🇦🇺</span><div><span class="text-xs font-medium text-zinc-900 dark:text-zinc-100">Australia</span><span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1.5">1<!-- --> <!-- -->book</span></div></div><div class="flex items-center gap-2"><span class="text-xl leading-none" role="img" aria-label="New Zealand">🇳🇿</span><div><span class="text-xs font-medium text-zinc-900 dark:text-zinc-100">New Zealand</span><span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1.5">1<!-- --> <!-- -->book</span></div></div><div class="flex items-center gap-2"><span class="text-xl leading-none" role="img" aria-label="Bulgaria">🇧🇬</span><div><span class="text-xs font-medium text-zinc-900 dark:text-zinc-100">Bulgaria</span><span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1.5">1<!-- --> <!-- -->book</span></div></div></div></div>
<h3 id="why-106-books">Why 106 Books?</h3>
<p>I used to read a lot, but during the last few years the average amount of books I read has been around 20-30. For me that is not a lot, and while I would like to argue that I'm reading on my computer every day (docs, articles, blog posts), I don't think it counts. It doesn't capture the feeling of finishing a good book at all.</p>
<p>Talking of screens, I, like so many other millennials, have a bad habit of embracing my phone the moment the real world stops giving me dopamine. So in the beginning of this year, I blocked the two main dopamine sources I had on my phone (Twitter and Reddit), and made Kindle app the only widget on my lock and phone screens. Now I can really only use my phone for being in contact with people, or for reading. On average I'm spending 6 hours in the Kindle app, and the other apps that I use are Whatsapp, Slack, Safari, and none of those come close to the kindle numbers.</p>
<p>Lastly, writing is my job (well, part of it), and the only way to become better at writing is to write and read more. So in a way, I can say I'm reading my way a promotion (one can dream).</p>
<h2 id="the-actual-books">The actual books</h2>
<p>Ok, let's not waste anymore time and get into the actual interesting part.</p>
<h3 id="currently-reading">Currently reading</h3>
<p>I'm reading multiple books at the same time; I've found it to be the easiest way for me to move books into the read category. Focusing on single book will stall my reading habit, as soon as the book gets even a tiny bit uninteresting.</p>
<div class="not-prose"><div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-x-4 gap-y-8"><div class="group cursor-pointer"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="American Psycho" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F8401686-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div><div class="mt-3"><div class="min-w-0"><h3 class="text-sm font-medium text-zinc-900 dark:text-zinc-100 leading-tight line-clamp-2">American Psycho</h3><p class="text-xs text-zinc-600 dark:text-zinc-400 mt-0.5 line-clamp-1">Bret Easton Ellis</p></div></div></div><div class="group cursor-pointer"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Careless People" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14859553-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div><div class="mt-3"><div class="min-w-0"><h3 class="text-sm font-medium text-zinc-900 dark:text-zinc-100 leading-tight line-clamp-2">Careless People</h3><p class="text-xs text-zinc-600 dark:text-zinc-400 mt-0.5 line-clamp-1">Unknown Author</p></div></div></div><div class="group cursor-pointer"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Time Shelter - a Novel" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13136172-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div><div class="mt-3"><div class="min-w-0"><h3 class="text-sm font-medium text-zinc-900 dark:text-zinc-100 leading-tight line-clamp-2">Time Shelter - a Novel</h3><p class="text-xs text-zinc-600 dark:text-zinc-400 mt-0.5 line-clamp-1">Georgi Gospodinov</p></div></div></div><div class="group cursor-pointer"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Japanese Myths" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F12861461-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div><div class="mt-3"><div class="min-w-0"><h3 class="text-sm font-medium text-zinc-900 dark:text-zinc-100 leading-tight line-clamp-2">Japanese Myths</h3><p class="text-xs text-zinc-600 dark:text-zinc-400 mt-0.5 line-clamp-1">Joshua Frydman</p></div></div></div></div></div>
<h3 id="read">Read</h3>
<p>Books I've read so far. I try to add a small note to everyone of them. It's taking a little bit of time to move the already written notes from my week notes emails here, so check in few days and I should have all of them in place.</p>
<div class="not-prose"><div class="flex items-center justify-between gap-4 mb-4"><div class="flex gap-2"><button class="px-3 py-1.5 text-xs font-mono uppercase tracking-wider transition-all bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900">Date <!-- -->↓</button><button class="px-3 py-1.5 text-xs font-mono uppercase tracking-wider transition-all bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700">Rating </button></div><div class="flex gap-1 border border-zinc-200 dark:border-zinc-800 p-1"><button class="p-1.5 transition-all text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300" aria-label="Grid view"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg></button><button class="p-1.5 transition-all bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900" aria-label="List view"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"></path></svg></button></div></div><div class="divide-y divide-zinc-200 dark:divide-zinc-800"><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="The moustache" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780340508022-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">The moustache</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Emmanuel Carrère</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->9</span><span class="text-xs text-zinc-500">143<!-- --> pages</span></div></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Margo's Got Money Troubles" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14745287-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">Margo's Got Money Troubles</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Rufi Thorpe</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->8</span><span class="text-xs text-zinc-500">416<!-- --> pages</span></div></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="This Inevitable Ruin" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142977-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">This Inevitable Ruin</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Matt Dinniman</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->8</span><span class="text-xs text-zinc-500">880<!-- --> pages</span></div></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Sky Daddy" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9780593231494-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">Sky Daddy</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Kate Folk</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->8</span><span class="text-xs text-zinc-500">368<!-- --> pages</span></div></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="The Eye of the Bedlam Bride" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14425808-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">The Eye of the Bedlam Bride</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Matt Dinniman</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->7</span><span class="text-xs text-zinc-500">692<!-- --> pages</span></div></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="How to Keep House While Drowning" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141805-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">How to Keep House While Drowning</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">K. C. Davis</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->7</span><span class="text-xs text-zinc-500">64<!-- --> pages</span></div></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><div class="w-full h-full bg-gradient-to-br from-amber-900 to-stone-900 flex items-end p-3 relative"><div class="absolute inset-0 opacity-10"><svg class="w-full h-full" viewBox="0 0 100 150" fill="none"><pattern id="pattern-Unknown-(9781647397067)" patternUnits="userSpaceOnUse" width="20" height="20"><circle cx="10" cy="10" r="1" fill="currentColor" class="text-white"></circle></pattern><rect width="100" height="150" fill="url(#pattern-Unknown-(9781647397067))"></rect></svg></div><div class="relative z-10"><p class="text-xs font-semibold text-white leading-tight line-clamp-3">Unknown (9781647397067)</p></div></div><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">Unknown (9781647397067)</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Unknown</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->7</span></div></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Snow Woman and Other Yokai Stories from Japan" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fisbn%2F9784805317587-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">Snow Woman and Other Yokai Stories from Japan</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Noboru Wada</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->5</span><span class="text-xs text-zinc-500">288<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">Honestly, I expected more from this one. This book is literally just a collection of stories, written in a way that doesn't explain any of the culture or history of the stories. Hopefully the 'The Japanese Myths' I'm reading as well ends up being better.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="The Butcher’s Masquerade" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14839706-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">The Butcher’s Masquerade</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Matt Dinniman</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->5</span><span class="text-xs text-zinc-500">720<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">Fifth in the Dungeon Crawler Carl series. Best so far. Well, who am I kidding, I've given 5 stars to all of them. But this one is especially good.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Weapons of math destruction : how big data increases inequality and threatens democracy" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14632952-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">Weapons of math destruction : how big data increases inequality and threatens democracy</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Unknown Author</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->5</span><span class="text-xs text-zinc-500">275<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">Before we got our current AI overlords, world was ran by mathematical (big data) models that decided your insurance, whether you were hireable or not, and even pushed you to vote. This book is about the perils of those algorithms. 

 This book came out in 2016, and reading it now 10 years later, not that much has actually changed. Well, it is outdated, but same processes still run. Things just essentially got worse. Three stars because the book didn't have that much new information to tell. Perhaps these were groundbreaking insights in 2016, can't remember.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="The Gate of the Feral Gods" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F11703128-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">The Gate of the Feral Gods</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Unknown Author</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->4</span><span class="text-xs text-zinc-500">582<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">It just keeps getting better. It gets so crazy towards the end of this book that I was just openly cheering in my hotel room. Also, holy fuck that plot twist.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="A Lonely Girl is a Dangerous Thing" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F10846012-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">A Lonely Girl is a Dangerous Thing</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Jessie Tu</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->4</span><span class="text-xs text-zinc-500">304<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">A former child prodigy in violin is now a sex addict. Selfishness, impulsiveness and self-harming through actions in Australia. 

 Bought this in Singapore, from a bookstore called Book Bar (definitely worth visiting). Read on the flight back to Finland. It will make you think, but the story is secondary. Wouldn't read it again.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="A Sunny Place for Shady People" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822536-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">A Sunny Place for Shady People</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Mariana Enriquez</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->3</span><span class="text-xs text-zinc-500">272<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">Horror stories as well. In my opinion more fun than Of the Flesh. Probably because this one is from a single author. I've read one other book from the same author (Our Share of Night), and thought it was decent. This one I enjoyed more, probably because the other book felt needlessly long.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Carl's Doomsday Scenario" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15116733-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">Carl's Doomsday Scenario</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Unknown Author</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->3</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">Second installment in the Dungeon Crawler Carl series, and the one in which you start to see the bigger picture of the story Matt Dinniman is creating. Even crazier than the first one, and because of that even more fun. The OG covers are absolutely hideous, but somehow charming.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="The Dungeon Anarchist’s Cookbook" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14822726-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">The Dungeon Anarchist’s Cookbook</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Matt Dinniman</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->3</span><span class="text-xs text-zinc-500">528<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">This is the book where characters start to see development. I'm in love with the 'You will not break me' mantra and all of its variations.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="2054" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14595672-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">2054</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Elliot Ackerman</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->2</span><span class="text-xs text-zinc-500">304<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">One of the Christmas presents I got, name comes from the fact that it's set in the future. Premise sounded interesting, authors sounded like an interesting combo (ex military). Premise of the book can be summed with the word 'singularity'. Falls short on writing, characters, and tension. Also it's kinda funny how much the characters make references to Covid 19, and the pandemic doesn't even have anything to do with the plot.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="The anatomy of story" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13319547-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">The anatomy of story</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">John Truby</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->2</span><span class="text-xs text-zinc-500">445<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">I have a hobby of reading screenplays, and books about writing good screenplays. This one is probably the most well known (or is it Save the cat? Not sure), and works more like workbook than a book that you read in bed. While I enjoyed the book a lot, I don't know how much I actually got out of it, as I don't really spend time writing screenplays (yet). Would though read if you're a movie buff.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Dungeon Crawler Carl" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15142881-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">Dungeon Crawler Carl</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Matt Dinniman</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->1</span><span class="text-xs text-zinc-500">444<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">Not sure how to summarize this book, but here goes: earth gets invaded, a beefcake and his cat are forced to compete in a dungeon, run by an AI. Cat learns to talk, they kill a lot of goblins. Things are really weird. AI has foot fetish. 

 I bought this book because the cover looked nice (the current ones, the original ones are absolutely hideous). It was also a risk buy because I thought I would not like the premise (it just felt a little childish). Things took a quick turn and this book series has become my favorite thing. I keep advocating to everyone. It's almost impossibly fun to read, and just keeps getting better.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Of the Flesh" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F15141349-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">Of the Flesh</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Unknown Author</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->1</span><span class="text-xs text-zinc-500">384<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">Collection of horror stories. Some not weird enough to be good, some too weird to be good. Can't name a favorite one (except maybe the first one because it ends in such a funny way).</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="The Atlas Six" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F13884252-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">The Atlas Six</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Olivie Blake</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->1</span><span class="text-xs text-zinc-500">448<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">Booktok book. Predictable writing about young people with magic skills in a modern world; that doesn't actually go anywhere (because this is the first part in a series, who would have guessed). Bought it because I've seen it in so many bookstores and the frequency bias made me expect it to be good. Well it's alright, but I'm still contemplating if I'll read the other books in the series or just leave this as the entry into a series that I abandoned.</p></div></article><article class="flex gap-4 sm:gap-6 py-6 border-b border-zinc-200 dark:border-zinc-800 last:border-b-0"><div class="w-20 sm:w-24 flex-shrink-0"><div class="aspect-[2/3] w-full relative group-hover:scale-[1.02] transition-transform"><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] shadow-[2px_4px_12px_rgba(0,0,0,0.15)] dark:shadow-[2px_4px_12px_rgba(0,0,0,0.4)]"></div><div class="relative w-full h-full rounded-r-sm rounded-l-[2px] overflow-hidden"><img alt="Several People Are Typing" loading="lazy" decoding="async" data-nimg="fill" class="object-cover" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent" sizes="150px" srcset="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=32&amp;q=75 32w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=48&amp;q=75 48w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=64&amp;q=75 64w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=96&amp;q=75 96w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=128&amp;q=75 128w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=256&amp;q=75 256w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=384&amp;q=75 384w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=640&amp;q=75 640w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=750&amp;q=75 750w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=828&amp;q=75 828w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=1080&amp;q=75 1080w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=1200&amp;q=75 1200w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=1920&amp;q=75 1920w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=2048&amp;q=75 2048w, /_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=3840&amp;q=75 3840w" src="/_next/image?url=https%3A%2F%2Fcovers.openlibrary.org%2Fb%2Fid%2F14711842-L.jpg&amp;w=3840&amp;q=75"><div class="absolute left-0 top-0 bottom-0 w-[6px] pointer-events-none"><div class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent"></div><div class="absolute left-[3px] top-0 bottom-0 w-[1px] bg-white/10"></div></div><div class="absolute inset-0 pointer-events-none opacity-[0.08] mix-blend-overlay"><div style="width:100%;height:100%"></div></div><div class="absolute inset-0 rounded-r-sm rounded-l-[2px] pointer-events-none border border-white/5 border-l-0"></div></div></div></div><div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-zinc-100 leading-tight">Several People Are Typing</h3><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-1">Calvin Kasulke</p><div class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2"><div class="flex gap-0.5 mt-1.5"><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-900 dark:text-zinc-100">★</span><span class="text-[10px] text-zinc-300 dark:text-zinc-700">★</span></div><span class="text-xs text-zinc-500">Week <!-- -->1</span><span class="text-xs text-zinc-500">256<!-- --> pages</span></div><p class="text-sm text-zinc-600 dark:text-zinc-400 mt-3 italic leading-relaxed whitespace-pre-line">Man gets uploaded to Slack and which everyone else at the public relations firm think is just a bit to exploit the company's work from home policy. Chaos ensues. 

 Nice quick read. Book is written in the form of discussion on Slack so despite being 250+ pages, you can read it in an hour or so.</p></div></article></div></div>
<h3 id="how-im-reading">How I'm reading</h3>
<p>This list of books consists of only read books, so no audio books are counted into these numbers. Not because I have anything against audiobooks, but because I this year I'm actively trying to focus on reading, not "listening and doing something else at the same time". Adding audiobooks would probably allow me to "consume" even more books, but I don't really see the point to just consuming books.</p>
<p>Anyway, here's how I'm reading:</p>
<ul>
<li>Old Kindle from 2019. Carry this with me all the time in my bag. Whipping it out as soon as I know have to be in place for more than 5 minutes.</li>
<li>iPhone and the Kindle app. I tend to pull out my phone almost habitually, so I actually get a lot of reading done with my phone. For example I read almost all of the fifth Dungeon Crawler Carl book from my phone, while we were having our developer advocate offsite in Singapore (read everytime I was in a taxi or a train)</li>
<li>Physical books. I love buying these and I love reading these. More than I like reading on digital screens. I'm usually carrying at least two books in my backbag, one that is almost finished and one that I have not started.</li>
</ul>
<figure><img alt="Kindle e-reader, iPhone with Kindle app, and physical books on a wooden table" loading="lazy" width="4032" height="3024" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Freaders.9769d195.jpeg&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX 1x" src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Freaders.9769d195.jpeg&amp;w=3840&amp;q=75&amp;dpl=dpl_7ak3jWr5vSnfpXXjAcUbdJJpidnX"><figcaption>I prefer physical books, but I've been reading the Dungeon Crawler Carl series especially on e-readers and phones.</figcaption></figure>
<h3 id="how-this-page-is-built">How this page is built</h3>
<p>So this page is slightly over engineered 😅 Made a heavy use of Claude Code for building all of this.</p>
<ul>
<li><strong>Data storage</strong>: Books are stored in simple JSON files (<code>read.json</code>, <code>reading.json</code>, <code>upcoming.json</code>) with just ISBN, rating, week finished, and notes</li>
<li><strong>Metadata fetching</strong>: A build script fetches book details (title, author, cover, page count) from Open Library API with Google Books as fallback, cached for 30 days</li>
<li><strong>Cover images</strong>: Pulled from Open Library's cover API, with generated gradient placeholders for books without covers</li>
<li><strong>Stats calculation</strong>: Progress, pace, and projections are calculated client-side based on current date and books read</li>
<li><strong>OG image</strong>: Dynamically generated at build time using Next.js OG image generation, featuring the 6 most recently read book covers in a fan arrangement, Has a tiny isometric image of me reading on my favorite chair</li>
</ul>]]></content:encoded>
            <author>hey@perttu.dev (Perttu Lähteenlahti)</author>
        </item>
    </channel>
</rss>