[{"content":"Since the early days of poketto.me, I\u0026rsquo;ve been dissatisfied with my persistence architecture. I started with Google Cloud SQL, but I quickly realized that it was too expensive for a small app. I switched to Firebase and had Claude do the replatforming, if you remember. However, when I introduced full-text search, I had to add BigQuery as a second database just for that because Firebase doesn\u0026rsquo;t have full-text indexing. The point is: It\u0026rsquo;s a lot of headaches for something that should be easy.\nEnter: Supabase.\nSupabase is a cloud-hosted Postgres database that comes with full SQL support Additionally, it has a convenient fluent API for Python.\nCheck it out:\nresponse = supabase.table(\u0026#34;saves\u0026#34;) .select(\u0026#34;id, title, saved_at\u0026#34;) .eq(\u0026#34;user_id\u0026#34;, some_user_id) .execute() And full-text indexing is basically built-in:\nresponse = supabase.table(\u0026#34;saves\u0026#34;).text_search(\u0026#39;fts\u0026#39;, search_term, options={ config\u0026#39;: \u0026#39;english\u0026#39;, \u0026#39;type\u0026#39;: \u0026#39;phrase\u0026#39; }) Additionally, it supports vector embeddings with the pgvector extension. Therefore, if you plan to use your data in an RAG pipeline1, you won\u0026rsquo;t need a separate vector store database.\nIt also comes with an intuitive web interface\u0026mdash;anyone remember phpMyAdmin?\nBut the killer feature for me: Their free tier is really generous!\n50,000 monthly active users 5 GB egress Runs on a VM with a shared CPU and\u0026hellip; 8 GB storage2 500 MB RAM Needless to say, if I had known about Supabase from the beginning, I would have built poketto.me with it. However, it\u0026rsquo;s never too late to migrate!\nSomething I wouldn\u0026rsquo;t recommend for most use cases. See here.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nOne thing I couldn\u0026rsquo;t figure out was whether you can actually use all 8 GB of disk space for the database. The documentation hinted at a \u0026ldquo;500 MB limit\u0026rdquo; for the database itself, but: One of my projects runs happily with a 1.17 GB database, so\u0026hellip; 🤷\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","permalink":"https://build.ralphmayr.com/posts/98-supabase-is-my-new-favorite-database/","summary":"\u003cp\u003eSince the early days of poketto.me, I\u0026rsquo;ve been dissatisfied with my persistence architecture. I started with Google Cloud SQL, but I quickly realized that it was \n\u003ca href=\"../6-cloudsql-is-prohibitively-expensive-at-least-for-small-projects/\"\u003etoo expensive\u003c/a\u003e for a small app. I switched to Firebase and \n\u003ca href=\"../7-refactoring-legacy-code-let-the-ai-handle-it/\"\u003ehad Claude do the replatforming\u003c/a\u003e, if you remember. However, when I introduced full-text search, I had to \n\u003ca href=\"../94-bigquerys-search-function-only-works-with-ascii-characters/\"\u003eadd BigQuery as a second database\u003c/a\u003e just for that because Firebase doesn\u0026rsquo;t have full-text indexing. The point is: It\u0026rsquo;s a lot of headaches for something that should be easy.\u003c/p\u003e","title":"Supabase is my new favorite database"},{"content":"This one isn't entirely related to poketto.me, but it's still an interesting lesson I learned the hard way the other day: If you want to build a \u0026quot;simple\u0026quot; app that automatically posts content to your Instagram account, it's much more difficult than you'd think.\nFirst, there's a GitHub project called instagrapi that seems to offer a nice Python automation library for Instagram. It can post photos, videos, etc. Looks good, right? Don't use it. Ever. It\u0026rsquo;s based on unofficial APIs, and using it \u0026mdash; especially in a script running in a cloud environment \u0026mdash; will get your Instagram account blocked almost instantly.\nSo, back to the drawing board. Instead, build on top of Meta\u0026rsquo;s official \u0026ldquo;Graph API.\u0026rdquo; But that\u0026rsquo;s far from easy. To do so, so only have to follow these eleven simple steps 😅\n1. Create a Facebook account (yikes!).\n2. Create an app on Meta\u0026rsquo;s developer portal with the \u0026ldquo;right\u0026rdquo; access permissions.\n3. Obtain an app ID and app secret for your app.\n4. Convert your Instagram account to a Creator or Business account.\n5. Link your Facebook account to your Instagram account (via a \u0026quot;meta\u0026quot; Meta account \u0026mdash; no pun intended 😆)\n6. Create a Facebook page (double yikes!).\n7. Link your Facebook page to your Instagram account.\n8. Add your Instagram account as a \u0026quot;Test User\u0026quot; to your app.\n9. Obtain an access token for your app and Instagram account using Meta\u0026rsquo;s Graph Explorer.\n10. Convert your \u0026quot;short-lived\u0026quot; access token to a \u0026quot;long-lived\u0026quot; access token using Meta's \u0026quot;Token Debugger\u0026quot; tool.\n11. Finally, write the actual Python script that calls the Graph API, for example, via the Pystagram wrapper library, to automate your use case.\nNeedless to say, most of the tutorials and advice from Gemini, Claude, and ChatGPT have one or more of these steps wrong.\nThe major pitfall: Meta recently changed its Developer Portal, making it nearly impossible to create an app that can request access to Instagram accounts later on. If you create a new app and follow the default workflow, the option to access the Instagram API won't be available. Instead, when prompted to select \u0026quot;use cases\u0026quot; for your app, pick \u0026quot;Other,\u0026quot; which will give you the \u0026quot;old experience\u0026quot; (Meta's wording!). Then, after the app is created, you can select Instagram as a \u0026quot;product\u0026quot; and, later, use the Graph Explorer to request the \u0026quot;instagram_content_publish\u0026quot; permission.\n","permalink":"https://build.ralphmayr.com/posts/97-automating-instagram-is-a-nightmare/","summary":"\u003cp\u003eThis one isn't entirely related to poketto.me, but it's still an interesting lesson I learned the hard way the other day: If you want to build a \u0026quot;simple\u0026quot; app that automatically posts content to your Instagram account, it's much more difficult than you'd think.\u003c/p\u003e\n\u003cp\u003eFirst, there's a GitHub project called instagrapi that seems to offer a nice Python automation library for Instagram. It can post photos, videos, etc. Looks good, right? Don't use it. Ever. It\u0026rsquo;s based on unofficial APIs, and using it \u0026mdash; especially in a script running in a cloud environment \u0026mdash; will get your Instagram account blocked almost instantly.\u003c/p\u003e","title":"Automating Instagram is a nightmare!"},{"content":"I\u0026rsquo;ve noticed a welcome uptick in users saving Wikipedia articles to poketto.me recently.\nBut until now, the app treated Wikipedia just like any other website: it scraped the raw HTML. Turns out, for Wikipedia, that is far from ideal:\n🤯 Artifacts: The extracted content often included UI clutter like \u0026ldquo;Edit\u0026rdquo; buttons, navigation links, and \u0026ldquo;Citation missing\u0026rdquo; tags.\n📋 Rendering issues: The standard HTML → Markdown → HTML conversion pipeline introduced plenty of ugly formatting glitches specific to wikis.\n🌎 Bad citizenship: The Wikimedia Foundation actively discourages scraping. They encourage developers to access their data through official channels.\nSo, I investigated their API, and honestly? It's a much better solution.\n🔎 Through the API, poketto.me now accesses the \u0026ldquo;clean\u0026rdquo; HTML content of any article directly, bypassing messy scraping and conversion.\n🚧 The rate limits on the free tier are quite generous too: 5,000 on-demand requests per month.\nThe engineering hurdle: The only minor challenge was that users save URLs, but the Wikimedia API requires searching by article title.\nThe fix? poketto.me now first extracts the title and respective subdomain directly from the saved URL. It then uses those details to query the API for the clean content.\nThe experience for the user remains exactly the same, but the quality of the saved article is significantly better. Plus, I\u0026rsquo;m doing Jimmy Wales a favour by sticking to the official rules. 😃\n","permalink":"https://build.ralphmayr.com/posts/96-stopping-the-scrape-why-i-switched-to-the-wikimedia-api/","summary":"\u003cp\u003eI\u0026rsquo;ve noticed a welcome uptick in users saving Wikipedia articles to poketto.me recently.\u003c/p\u003e\n\u003cp\u003eBut until now, the app treated Wikipedia just like any other website: it scraped the raw HTML. Turns out, for Wikipedia, that is far from ideal:\u003c/p\u003e\n\u003cp\u003e🤯 Artifacts: The extracted content often included UI clutter like \u0026ldquo;Edit\u0026rdquo; buttons, navigation links, and \u0026ldquo;Citation missing\u0026rdquo; tags.\u003c/p\u003e\n\u003cp\u003e📋 Rendering issues: The standard HTML → Markdown → HTML conversion pipeline introduced plenty of ugly formatting glitches specific to wikis.\u003c/p\u003e","title":"Stopping the scrape: Why I switched to the Wikimedia API"},{"content":"One of the great things about working on poketto.me is that I'm constantly learning about fascinating linguistic subtleties. For instance, while working on automatic content summaries and extracting key facts and figures, I came across an interesting issue with token counting in Chinese script.\nI had put a safeguard in place so that poketto.me would only attempt to summarize content longer than 100 words. This works well for German and English content, but when I tested the feature on an article published by Xinhua, a Chinese news agency, my code said the article had only about 12 words, which was obviously incorrect, so it didn't produce a summary.\nWhat\u0026rsquo;s going on there?\n⚡ ️My naive approach to counting words was to split the content by whitespace, and count the number of resulting tokens. In Chinese though, whitespace doesn\u0026rsquo;t have the same significance as in English or German. The entire Xinhua article, for instance, only contains 11 whitespace characters but still is so long that it merits summarization. That's the entire first paragraph, for example, with only one \u0026quot;proper\u0026quot; whitespace character in it:\n当地时间10月31日上午，亚太经合组织第三十二次领导人非正式会议第一阶段会议在韩国庆州和白会议中心举行。国家主席习近平出席会议并发表题为《共建普惠包容的开放型亚太经济》的重要讲话\n💡I could have disregarded the minimum-length safeguard entirely, but instead, I searched for a proper solution to the word-counting issue. After shopping around for a while, I settled on Jieba, a Python word segmentation module specifically designed for the Chinese language.\n🇨🇳 With Jieba in place, word counting, reading time determination, content summarization, and all the other features that build on them work smoothly for Chinese content as well. 你好中文!\nCheck it out here: https://app.poketto.me/#/shared/ERDNBoi\n","permalink":"https://build.ralphmayr.com/posts/95-be-careful-when-counting-your-whitespace/","summary":"\u003cp\u003eOne of the great things about working on poketto.me is that I'm constantly learning about fascinating linguistic subtleties. For instance, while working on automatic content summaries and extracting key facts and figures, I came across an interesting issue with token counting in Chinese script.\u003c/p\u003e\n\u003cp\u003eI had put a safeguard in place so that poketto.me would only attempt to summarize content longer than 100 words. This works well for German and English content, but when I tested the feature on an article published by Xinhua, a Chinese news agency, my code said the article had only about 12 words, which was obviously incorrect, so it didn't produce a summary.\u003c/p\u003e","title":"Be careful when counting your whitespace"},{"content":"Admittedly, it may not have been my brightest idea to use BigQuery as the search backend for poketto.me. But since Firebase doesn\u0026rsquo;t have built-in full-text search, I would have needed to add another tool to my stack anyway. I figured BigQuery would be easier than managing something external like Apache Lucene or Elasticsearch. Plus, BigQuery has a built-in SEARCH(...) function, so why not give it a try?\nAs it turns out, SEARCH(...) is really more of a token-based text analyzer than a true search function:\n1️⃣ It splits both the search term and the text into tokens (words).\n2️⃣ It matches full tokens only.\n3️⃣ It returns just TRUE or FALSE\u0026mdash;no offsets, no match lengths.\n🤯 And worst of all: It only works with ASCII. Yes. Like it\u0026rsquo;s 1979.\nWith these limitations, my first iteration of full-text search in poketto.me was basically unusable:\n😟 Searching for entire words worked (\u0026ldquo;Finanzminister\u0026rdquo; returned results).\n😟 Partial words didn\u0026rsquo;t (\u0026ldquo;Finanzmin\u0026rdquo; returned nothing).\n😟 Non-ASCII characters broke it completely (\u0026ldquo;Österreich\u0026rdquo; yielded no results).\nSo what did I do? I fell back on the bluntest tool in my toolbox: REGEXP_CONTAINS(...).\nI know this isn\u0026rsquo;t a long-term solution, but for now, it works surprisingly well (and fast):\n1️⃣ I implemented my own tokenization logic\u0026mdash;splitting search terms into words, keeping quoted phrases together.\n2️⃣ I construct a long SQL query using REGEXP_CONTAINS to check titles, full text, site names, and bylines:\nREGEXP_CONTAINS(LOWER(text), LOWER(@search_term_{idx}))\n3️⃣ For each \u0026ldquo;found\u0026rdquo; save, I scan the full text again for offsets and lengths to highlight matches in the UI.\nClumsy? *Yes.* Sustainable? *Probably not.* But it works\u0026mdash;for now\u0026mdash;until I replace it with a real full-text search backend.\n🔗 [https://cloud.google.com/bigquery/docs/reference/standard-sql/search_functions#search]{.underline}\n🔗 [https://cloud.google.com/bigquery/docs/reference/standard-sql/string_functions#regexp_contains]{.underline}\n","permalink":"https://build.ralphmayr.com/posts/94-bigquerys-search-function-only-works-with-ascii-characters/","summary":"\u003cp\u003eAdmittedly, it may not have been my brightest idea to use \u003cstrong\u003eBigQuery\u003c/strong\u003e as the search backend for \u003cstrong\u003epoketto.me\u003c/strong\u003e. But since Firebase doesn\u0026rsquo;t have built-in full-text search, I would have needed to add another tool to my stack anyway. I figured BigQuery would be easier than managing something external like \u003cstrong\u003eApache Lucene\u003c/strong\u003e or \u003cstrong\u003eElasticsearch\u003c/strong\u003e. Plus, BigQuery has a built-in \u003cstrong\u003eSEARCH(...)\u003c/strong\u003e function, so why not give it a try?\u003c/p\u003e\n\u003cp\u003eAs it turns out, \u003cstrong\u003eSEARCH(...)\u003c/strong\u003e is really more of a token-based text analyzer than a true search function:\u003cbr\u003e\n1️⃣ It splits both the search term and the text into tokens (words).\u003cbr\u003e\n2️⃣ It matches \u003cem\u003efull\u003c/em\u003e tokens only.\u003cbr\u003e\n3️⃣ It returns just \u003cstrong\u003eTRUE\u003c/strong\u003e or \u003cstrong\u003eFALSE\u003c/strong\u003e\u0026mdash;no offsets, no match lengths.\u003cbr\u003e\n🤯 And worst of all: \u003cstrong\u003eIt only works with ASCII.\u003c/strong\u003e Yes. Like it\u0026rsquo;s 1979.\u003c/p\u003e","title":"BigQuery’s SEARCH function only works with ASCII characters"},{"content":"In Use friction to your advantage, I talked about ways to nudge users toward desired\u0026mdash;or away from undesired\u0026mdash;behaviours. Take this idea too far, however, and you end up with Dark Patterns: UI tricks that deliberately deceive users.\nE-commerce is full of bad examples:\n\u0026ndash; The \u0026ldquo;Only 3 Left In Stock!\u0026rdquo; badges that try to rush you into a purchase\n\u0026ndash; The awkward games and bonus points on the Temu app\n\u0026ndash; Cookie consent banners where the \u0026ldquo;Accept all\u0026rdquo; button looks like the only real option\nYou get the point.\nBut there\u0026rsquo;s a counter-movement to these deceptions that I\u0026rsquo;ll call \u0026ldquo;virtue signaling,\u0026rdquo; for lack of a better term. Here, you make the undesired action easily visible\u0026mdash;not because you want the user to take it, but to show that you\u0026rsquo;re not trying to deceive them.\nSome examples from poketto.me:\n🚪 First-time use workflow: I make the option to \u0026ldquo;Leave now and delete your account\u0026rdquo; visible and prominent. Not because I want people to click it, but because it shows them there\u0026rsquo;s a clear exit if they\u0026rsquo;re here by mistake.\n⚠️ Profile settings: The same option is only one click (and a confirmation dialog) away. Unlike, say, Facebook\u0026mdash;where deleting your account feels like running an obstacle course\u0026mdash;I want users to know they can leave any time, with all their data deleted and no questions asked.\n💰 Subscriptions section: I added a banner encouraging users to email me with any billing, terms, or payment questions. The thinking: People are more likely to subscribe if they know there\u0026rsquo;s a clear, easy way to get help\u0026mdash;or cancel\u0026mdash;if needed.\nOf course, I didn\u0026rsquo;t invent this idea. Frankly, I stole it from a well-known Austrian newspaper: Back when they ran radio commercials, they always highlighted that you could cancel \u0026ldquo;any day\u0026rdquo;, no strings attached. I\u0026rsquo;m not sure how many actually canceled\u0026mdash;but it certainly drove sign-ups, especially when subscriptions were notoriously hard to get rid of.\n","permalink":"https://build.ralphmayr.com/posts/93-use-virtue-signalling-but-only-if-youre-actually-planning-to-be-virtuous/","summary":"\u003cp\u003eIn \n\u003ca href=\"../90-use-friction-to-your-advantage/\"\u003eUse friction to your advantage\u003c/a\u003e, I talked about ways to nudge users toward desired\u0026mdash;or away from undesired\u0026mdash;behaviours. Take this idea too far, however, and you end up with Dark Patterns: UI tricks that deliberately deceive users.\u003c/p\u003e\n\u003cp\u003eE-commerce is full of bad examples:\u003c/p\u003e\n\u003cp\u003e\u0026ndash; The \u0026ldquo;Only 3 Left In Stock!\u0026rdquo; badges that try to rush you into a purchase\u003c/p\u003e\n\u003cp\u003e\u0026ndash; The awkward games and bonus points on the Temu app\u003c/p\u003e\n\u003cp\u003e\u0026ndash; Cookie consent banners where the \u0026ldquo;Accept all\u0026rdquo; button looks like the only real option\u003c/p\u003e","title":"Use virtue signalling (but only if you’re actually planning to be virtuous!)"},{"content":"Angular is awesome. And data binding, in particular, has been a game changer for developing modern web apps. Something changes somewhere and\u0026mdash;magically\u0026mdash;every part of your UI that needs to respond does so.\nHowever, I\u0026rsquo;ve always struggled with one corner case: What if you\u0026rsquo;re binding to an array, and the change that occurs is that something gets added or removed, and you need to respond to the change programmatically (not just in your HTML template)?\nSounds weird? Here\u0026rsquo;s the example:\n👉 In poketto.me, users can drag and drop files to upload\n👉 None of the files may exceed a file size limit\n👉 The total number of files may not exceed a user-specific limit\nSo my FileListComponent has to validate the entire array of files every time a file is added or removed.\n🙋‍♂️ I can\u0026rsquo;t do all that validating in the HTML template\n🙋‍♂️ I can\u0026rsquo;t rely on ngOnChanges, as this only fires when the reference to the array changes\n🙋‍♂️ I can\u0026rsquo;t fully rely on ngDoCheck because I only want to validate the changed files (not all of them every time)\nHere\u0026rsquo;s the solution:\nIn my component, I\u0026rsquo;m using a \u0026ldquo;differ\u0026rdquo;:\nprivate differ: any;\nI get the IterableDiffers injected via dependency injection:\nconstructor(private differs: IterableDiffers) { }\nThen, in the ngDoCheck() lifecycle hook, I can diff the file array using the differ and work with the changes:\nngDoCheck() { const changes = this.differ.diff(this.files); if (changes) { // ... } } ´´´ ","permalink":"https://build.ralphmayr.com/posts/92-angular-data-binding-with-arrays-yes-it-can-work/","summary":"\u003cp\u003eAngular is awesome. And data binding, in particular, has been a game changer for developing modern web apps. Something changes somewhere and\u0026mdash;magically\u0026mdash;every part of your UI that needs to respond does so.\u003c/p\u003e\n\u003cp\u003eHowever, I\u0026rsquo;ve always struggled with one corner case: What if you\u0026rsquo;re binding to an array, and the change that occurs is that something gets added or removed, and you need to respond to the change programmatically (not just in your HTML template)?\u003c/p\u003e","title":"Angular data binding with arrays: Yes, it can work!"},{"content":"Remember Capacitor + Android Status Bar = 🤯?\n🪲 This bug has haunted me for months\u0026mdash;stalling the Android release of poketto.me and draining way too much mental energy. It\u0026rsquo;s one of those dreaded dev problems: no obvious solution, hard to debug, and endless rabbit holes.\nEventually, I had to bite the bullet and dig in. Here\u0026rsquo;s what I found:\n💣 Issue #1: The Capacitor status bar plugin only half-works. There\u0026rsquo;s a StatusBar.setOverlaysWebView(true/false) API, but on modern Android versions it doesn\u0026rsquo;t behave as advertised. Why?\n💣 Issue #2: Since API level 35 (Android 15+), activities render \u0026ldquo;edge-to-edge\u0026rdquo; by default, something that the Capacitor status bar plug-in doesn\u0026rsquo;t seem to be aware of. You could fix that also in the layout XML of your Android activity, but\u0026hellip;\n💣 Issue #3: With Capacitor, you can\u0026rsquo;t directly modify the XML layout of the main activity (since it hosts Capacitor\u0026rsquo;s WebView).\nMy solution:\nOpt out of edge-to-edge rendering via the app\u0026rsquo;s theme. Plus: You can do that for specific API versions by creating a separate folder (called \u0026ldquo;values-35\u0026rdquo; in this case) and put a styles.xml in there which contains:\n\u0026lt;style name=\\\u0026#34;AppTheme.NoActionBar\\\u0026#34; parent=\\\u0026#34;Theme.AppCompat.NoActionBar\\\u0026#34;\\\u0026gt; \u0026lt;item name=\\\u0026#34;android:windowOptOutEdgeToEdgeEnforcement\\\u0026#34;\\\u0026gt;true\\\u0026lt;/item\\\u0026gt; \u0026lt;/style\\\u0026gt; Bonus:\nAndroid also exposes an API to recolor the status bar directly: window.setStatusBarColor(\\...)\nThe result:\n👉 The status bar renders outside the app, consistently across Android versions\n👉 By default, it uses the system color\n👉 When the user switches poketto.me\u0026rsquo;s reader theme (e.g. dark or midnight), the Angular app notifies the Android layer, which recolors the status bar on the fly\n😅 Way harder than it should have been\u0026mdash;but it finally works!\n","permalink":"https://build.ralphmayr.com/posts/91-how-to-fix-the-ominous-android-status-bar-issue/","summary":"\u003cp\u003eRemember \n\u003ca href=\"../28-capacitor-android-status-bar/\"\u003eCapacitor + Android Status Bar = 🤯\u003c/a\u003e?\u003c/p\u003e\n\u003cp\u003e🪲 This bug has haunted me for months\u0026mdash;stalling the Android release of poketto.me and draining way too much mental energy. It\u0026rsquo;s one of those dreaded dev problems: no obvious solution, hard to debug, and endless rabbit holes.\u003c/p\u003e\n\u003cp\u003eEventually, I had to bite the bullet and dig in. Here\u0026rsquo;s what I found:\u003c/p\u003e\n\u003cp\u003e💣 Issue #1: The Capacitor status bar plugin only half-works. There\u0026rsquo;s a StatusBar.setOverlaysWebView(true/false) API, but on modern Android versions it doesn\u0026rsquo;t behave as advertised. Why?\u003c/p\u003e","title":"How to fix the ominous Android Status Bar Issue"},{"content":"Amazon in general, and Jeff Bezos in particular, are famous for \u0026ldquo;reducing friction.\u0026rdquo; Case in point: One-click checkout. Allegedly, it was Bezos himself who obsessed over removing as many steps as possible from the customer\u0026rsquo;s path to purchase. Enter your shipping address? Confirm your credit card number? Validate the details? Gone. Just \u0026ldquo;Buy now,\u0026rdquo; and the goods will be in your mailbox tomorrow.\nFor poketto.me, I applied this principle to the signup process. What\u0026rsquo;s the simplest way to enroll for a new product or service? Do you really want to enter your name (first and last), email address (twice), a password (with arbitrary security criteria), then get a confirmation mail, click the link, and finally find yourself in another browser tab (or window)? By that time, many users would surely have given up.\nSo, I introduced \u0026ldquo;Sign in with Google\u0026rdquo; first\u0026mdash;one click, and your poketto.me account is set up and ready to use. \u0026ldquo;Continue with email\u0026rdquo; works almost as smoothly: Enter your email address, check your mail for the verification code, copy/paste it, and you\u0026rsquo;re good to go. In fact, I credit much of the initial upsurge in sign-ups to the simplicity of this process.\nBut there\u0026rsquo;s another part of friction that\u0026rsquo;s often overlooked: deliberate friction, used to gently nudge users away from things you don\u0026rsquo;t want them to do. Case in point: the Pocket data import.\nUsers can export their saved content from Pocket as a CSV file and import it into poketto.me. But that file can be huge\u0026mdash;in my own case, it had over 1,100 entries going back to August 2015. Naturally, I wanted users to import all their historical data into poketto.me. But I also didn\u0026rsquo;t want them to overwhelm the system. Fetching, streamlining, and possibly translating thousands of articles at once would have required a completely different architecture and far more infrastructure than I had planned for.\nSo the import process comes with a bit of healthy friction: Users can upload all their saves at once\u0026mdash;so far, so good. But then they have a choice: If they want the full \u0026ldquo;fetch / streamline / translate\u0026rdquo; experience, they have to click the \u0026ldquo;Save\u0026rdquo; button for each entry. Or they can import all saved URLs at once, but then only the URL, title, and \u0026ldquo;saved at\u0026rdquo; date will be stored.\n","permalink":"https://build.ralphmayr.com/posts/90-use-friction-to-your-advantage/","summary":"\u003cp\u003eAmazon in general, and Jeff Bezos in particular, are famous for \u0026ldquo;reducing friction.\u0026rdquo; Case in point: One-click checkout. Allegedly, it was Bezos himself who obsessed over removing as many steps as possible from the customer\u0026rsquo;s path to purchase. Enter your shipping address? Confirm your credit card number? Validate the details? Gone. Just \u0026ldquo;Buy now,\u0026rdquo; and the goods will be in your mailbox tomorrow.\u003c/p\u003e\n\u003cp\u003eFor poketto.me, I applied this principle to the signup process. What\u0026rsquo;s the simplest way to enroll for a new product or service? Do you really want to enter your name (first and last), email address (twice), a password (with arbitrary security criteria), then get a confirmation mail, click the link, and finally find yourself in another browser tab (or window)? By that time, many users would surely have given up.\u003c/p\u003e","title":"Use friction to your advantage"},{"content":"In TIL #14, I called getting an Android app into the Play Store \u0026ldquo;byzantine.\u0026rdquo; Turns out, I was being too generous to Google and too strict on the ancient kingdom of Byzantium.\nHere\u0026rsquo;s what really gave me headaches over the last few months:\n🤕 Headache #1: The forms\nBefore Google even looks at your app, you\u0026rsquo;re drowning in bureaucracy: ticking the \u0026ldquo;my app doesn\u0026rsquo;t process health data\u0026rdquo; box 12 times, pasting links to T\u0026amp;Cs and privacy policies, verifying your name, intentions, identity, blood type, shoe size, the maiden name of your mom\u0026rsquo;s dog, \u0026hellip;\n🤕 Headache #2: The test users\nTo move from \u0026ldquo;Closed Testing\u0026rdquo; to production, 12 real people (with unique Google accounts + devices) must install your app for 14 consecutive days. For an independent dev, that means pestering friends, family, and anyone with an Android phone. Corporate teams? Equally awkward, I imagine, having to bet employees to use personal Google accounts for testing.\n🤕 Headache #3: Updates during testing\nThink you can quickly ship updates in closed testing? Nope. Every build needs:\n👉A new version code (see TIL #47)\n👉Signing + uploading to the Google Play Developer Console \u0026ndash; the second most clumsy UI I\u0026rsquo;ve ever seen (only surpassed by Google Ads)\n👉 Several hours of waiting for Google\u0026rsquo;s blessing\n🤕 Headache #4: Login instructions\nOnce you finally apply for production, reviewers reject any app requiring login unless you provide credentials. For poketto.me, login = email + verification code. Easy, right? It sais so right there on the login page. Nope. Google wants username + password and instructions on where to put them. I ended up hard-coding a backdoor user for Google with a fixed verification code. But, according to my logs, they never even used that account.\n🤕 Headache #5: Appeals\nResubmit? File a support ticket? Wait a week? Sometimes Google closes tickets automatically because \u0026ldquo;the app is still under review.\u0026rdquo; On another page, it sais that developers located in the European Union have access to \u0026ldquo;additional means of appeal,\u0026rdquo; whatever that means. Turns out: Waiting int out is often your only option.\n🤕 Headache #6: Cross-blocking\nWhile one version is \u0026ldquo;under review\u0026rdquo; for production, you can\u0026rsquo;t update the version in closed testing, change the store listing, update your app icon, or, really, do anything.\n🥲 The only consolation?\nOnce in production, minor updates now pass review in hours. Small mercy.\n","permalink":"https://build.ralphmayr.com/posts/89-googles-play-store-review-process-is-pure-torture/","summary":"\u003cp\u003eIn \n\u003ca href=\"../14-the-process-to-get-an-app-into-google-play-is-byzantine/\"\u003eTIL #14\u003c/a\u003e, I called getting an Android app into the Play Store \u0026ldquo;byzantine.\u0026rdquo; Turns out, I was being too generous to Google and too strict on the ancient kingdom of Byzantium.\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s what really gave me headaches over the last few months:\u003c/p\u003e\n\u003cp\u003e🤕 Headache #1: The forms\u003c/p\u003e\n\u003cp\u003eBefore Google even looks at your app, you\u0026rsquo;re drowning in bureaucracy: ticking the \u0026ldquo;my app doesn\u0026rsquo;t process health data\u0026rdquo; box 12 times, pasting links to T\u0026amp;Cs and privacy policies, verifying your name, intentions, identity, blood type, shoe size, the maiden name of your mom\u0026rsquo;s dog, \u0026hellip;\u003c/p\u003e","title":"Google’s Play Store review process is pure torture"},{"content":"As I said in No, you don\u0026rsquo;t have to learn LangChain, we shouldn\u0026rsquo;t get distracted by the artificial complexity introduced by our frameworks. LangChain is mostly a wrapper around the REST APIs of various LLM providers. Useful? Yes\u0026mdash;switching between models becomes easy.\nBut here\u0026rsquo;s a mystery I can\u0026rsquo;t explain.\nWhen I added Gemini as a fallback to DeepSeek (see yesterday\u0026rsquo;s post about DeepSeek refusing to touch Chinese politics), I thought it would be straightforward:\nBy default, I\u0026rsquo;m instantiating my chat model thus:\nllm = init_chat_model(model=\\\u0026#39;deepseek-chat\\\u0026#39;, model_provider=\\\u0026#39;deepseek\\\u0026#39;) And, if a subsequent call would error out with DeepSeeks HTTP/400 \u0026ldquo;Content Exists Risk\u0026rdquo;, I\u0026rsquo;d go with Gemini instead:\nllm = init_chat_model(model=\\\u0026#39;gemini-2.5-flash\\\u0026#39;, model_provider=\\\u0026#39;google_vertexai\\\u0026#39;) So far, so good\u0026mdash;until I deployed it to CloudRun. There, Every time the fallback kicked in, the instance crashed:\nMemory limit of 512 MiB exceeded with 523 MiB used. Consider increasing the memory limit\u0026hellip;\nI dug deeper on my local machine: calling init_chat_model with google_vertexai instantly doubled the memory consumption of my Python process!\nI didn\u0026rsquo;t want to debug this forever, so I swapped Gemini out for Claude:\nllm = init_chat_model(\\\u0026#34;claude-3-5-sonnet-latest\\\u0026#34;, model_provider=\\\u0026#34;anthropic\\\u0026#34;) Result: No memory issues at all.\nSo\u0026hellip; riddle me this: why in the world would LangChain need 200+ MB of memory just to launch a Vertex AI chat model that ultimately just sends a REST call to Google?\n","permalink":"https://build.ralphmayr.com/posts/88-the-memory-consumption-patterns-of-langchain-are-disturbing/","summary":"\u003cp\u003eAs I said in \n\u003ca href=\"../21-no-you-dont-have-to-learn-langchain/\"\u003eNo, you don\u0026rsquo;t have to learn LangChain\u003c/a\u003e, we shouldn\u0026rsquo;t get distracted by the artificial complexity introduced by our frameworks. LangChain is mostly a wrapper around the REST APIs of various LLM providers. Useful? Yes\u0026mdash;switching between models becomes easy.\u003c/p\u003e\n\u003cp\u003eBut here\u0026rsquo;s a mystery I can\u0026rsquo;t explain.\u003c/p\u003e\n\u003cp\u003eWhen I added Gemini as a fallback to DeepSeek (see yesterday\u0026rsquo;s post about DeepSeek refusing to touch Chinese politics), I thought it would be straightforward:\u003c/p\u003e","title":"The memory consumption patterns of LangChain are… disturbing"},{"content":"For most use cases in poketto.me, I\u0026rsquo;m pretty happy with #DeepSeek: it\u0026rsquo;s cheap, reliable, and the output quality matches any other LLM I\u0026rsquo;ve tried.\nBut there\u0026rsquo;s one big caveat: anything related to Chinese politics can trigger an immediate refusal. Example: Right after the launch of poketto.me, a user tried saving an article about the September 3rd Beijing meeting between Xi Jinping, Vladimir Putin, Kim Jong Un, et al. ( https://orf.at/stories/3404330/)\nInstead of a summary, poketto.me just kept showing the \u0026ldquo;processing\u0026rdquo; spinner forever.\nAfter some digging, I found the culprit:\n\u0026ndash; The DeepSeek API returned an HTTP/400 status code.\n\u0026ndash; The error message? Invalid Request: Content Exists Risk (their wording, not mine 🤷).\nMy fix: If DeepSeek refuses a request, I now automatically fall back to Gemini, which I\u0026rsquo;m already using for YouTube transcripts.\nProblem solved\u0026mdash;at least for now.\n","permalink":"https://build.ralphmayr.com/posts/87-deepseek-really-wont-touch-anything-related-to-chinese-politics/","summary":"\u003cp\u003eFor most use cases in poketto.me, I\u0026rsquo;m pretty happy with #DeepSeek: it\u0026rsquo;s cheap, reliable, and the output quality matches any other LLM I\u0026rsquo;ve tried.\u003c/p\u003e\n\u003cp\u003eBut there\u0026rsquo;s one big caveat: anything related to Chinese politics can trigger an immediate refusal. Example: Right after the launch of poketto.me, a user tried saving an article about the September 3rd Beijing meeting between Xi Jinping, Vladimir Putin, Kim Jong Un, et al. (\n\u003ca href=\"https://orf.at/stories/3404330/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehttps://orf.at/stories/3404330/\u003c/a\u003e)\u003c/p\u003e","title":"DeepSeek really won’t touch anything related to Chinese politics"},{"content":"Marketing done well is so much more than cranking out ad copy or polishing sales slides. In all my corporate product roles\u0026mdash;Fabasoft, Borland / Micro Focus, smec\u0026mdash;I got to work with fantastic marketing teams. They shaped products, challenged ideas, and saw the bigger picture we tech-focused product folks often missed.\nWith poketto.me, though, it was just me. So I leaned on ChatGPT, Grok, and Gemini more often than I liked\u0026mdash;sometimes with good results, sometimes\u0026hellip; not so much.\nHere\u0026rsquo;s how that went:\n1️⃣ The good: \u0026ldquo;Shorten this text\u0026rdquo;\nFor the official launch email on Sept 2, I wrote a long piece introducing features, pricing, and the future vision. But I also needed short versions for Bluesky, X, and LinkedIn. Turns out: ChatGPT is excellent at compressing long-form text without losing the core message. Check out Exhibit A!\n2️⃣ The okay: \u0026ldquo;Variations on a theme\u0026rdquo;\nThe personal podcasts feature in poketto.me is pretty generic: Turn any content into speech, listen anytime, anywhere. Specificity sells, so I asked the bots for use cases, branding ideas, and taglines.\n\u0026ndash; All three suggested \u0026ldquo;ListenLater.\u0026rdquo;\n\u0026ndash; Many pitched \u0026ldquo;\u0026hellip;Cast\u0026rdquo; names (too obvious).\n\u0026ndash; A few were original: \u0026ldquo;Ear Mark\u0026rdquo; (Gemini) and \u0026ldquo;Audibly Yours\u0026rdquo; stood out as clever wordplay.\n3️⃣ The mediocre: \u0026ldquo;Ask me critical questions\u0026rdquo;\nAs an optimist, I tend to overlook risks. So I asked the bots for five critical questions about the podcast feature. Result: softball questions. Checking them off would give a false sense of security\u0026mdash;the real pitfalls weren\u0026rsquo;t even mentioned. As I said in my post about \u0026ldquo;The Bright Side\u0026rdquo; a few weeks back: Here\u0026rsquo;s where the combination of a dispositional optimist and a critically minded pessimist would stand out.\nThe 15 total questions roughly clustered around 🟢 Persona \u0026amp; problem, 🟠 UX \u0026amp; personalization, 🟣 Legal \u0026amp; privacy, Monetization (just one question!). See Exhibit C.\nBottom line: AI can help with speed and brainstorming, but it still can\u0026rsquo;t replace the strategic thinking of a real marketing team.\n","permalink":"https://build.ralphmayr.com/posts/86-ai-cant-replace-a-great-marketing-team-but-sometimes-its-better-than-nothing/","summary":"\u003cp\u003eMarketing done well is so much more than cranking out ad copy or polishing sales slides. In all my corporate product roles\u0026mdash;Fabasoft, Borland / Micro Focus, smec\u0026mdash;I got to work with fantastic marketing teams. They shaped products, challenged ideas, and saw the bigger picture we tech-focused product folks often missed.\u003c/p\u003e\n\u003cp\u003eWith poketto.me, though, it was just me. So I leaned on ChatGPT, Grok, and Gemini more often than I liked\u0026mdash;sometimes with good results, sometimes\u0026hellip; not so much.\u003c/p\u003e","title":"AI can’t replace a great marketing team (but sometimes it’s better than nothing)"},{"content":"As I mentioned in GMail\u0026rsquo;s spam filter is pretty weird, reliably sending emails is harder than it looks. But email marketing automation is also one of the most powerful tools you can add to your stack.\nSo, I went looking for a simple, cheap, API-based solution I could plug into poketto.me. Ideally:\n✔️ Free for small usage (given my current revenue = zero)\n✔️ Easy integration with my stack existing stack (Python backend, Posthog for analytics)\n✔️ Scalable once I need more\nAfter asking around, I landed on Klaviyo\u0026mdash;a service I vaguely remembered as \u0026ldquo;just a Shopify plug-in.\u0026rdquo; Turns out, it\u0026rsquo;s much more than that. E-commerce might be their bread and butter, but Klaviyo works great beyond that. Here\u0026rsquo;s how I\u0026rsquo;m using it with poketto.me:\n👉 Welcome emails: For about three months, I manually sent \u0026ldquo;Welcome to poketto.me!\u0026rdquo; emails to new users. Now, it\u0026rsquo;s fully automated. This also lays the foundation for a proper onboarding flow: multiple emails over the first 2\u0026ndash;3 weeks, with tips, tricks, and feature highlights.\n👉 Launch email: A \u0026ldquo;poketto.me has launched!\u0026rdquo; announcement was the bare minimum I wanted for launch comms. But by September, I had way too many users to send from my personal inbox. Plus, I wanted a professional look and feel.\nSo, I imported all users into Klaviyo, created a Launch Mail Audience list, and spent half a day on templates, branding, and content. Then I hit send 🚀.\n👉 Integration: In theory, I could have connected Klaviyo via Posthog data pipelines\u0026mdash;but those aren\u0026rsquo;t included in the free Posthog plan. Instead, I used Klaviyo\u0026rsquo;s REST API and built my own integration:\n🔗 On signup: create a \u0026ldquo;Profile\u0026rdquo; in Klaviyo and add the user to the All Users list\n🔗 Adding to the list automatically triggers the Welcome Email flow (currently just one email with a 24-hour delay)\n🔗 On account deletion: remove the user from Klaviyo again\nSimple, clean, and scalable\u0026mdash;without paying a cent (yet).\nNaturally, the possibilities for further enhancements are endless:\n📧 Trigger a sophisticated flow for users who upgraded from free to premium or unlimited\n📧 Regular new feature announcements\n📧 Semi-automated content newsletters (\u0026ldquo;Five articles we love\u0026rdquo;)\n📧 \u0026hellip;\n","permalink":"https://build.ralphmayr.com/posts/85-klaviyo-much-more-than-a-shopify-plug-in/","summary":"\u003cp\u003eAs I mentioned in \n\u003ca href=\"../22-gmails-spam-filter-is-pretty-weird/\"\u003eGMail\u0026rsquo;s spam filter is pretty weird\u003c/a\u003e, reliably sending emails is harder than it looks. But email marketing automation is also one of the most powerful tools you can add to your stack.\u003c/p\u003e\n\u003cp\u003eSo, I went looking for a simple, cheap, API-based solution I could plug into poketto.me. Ideally:\u003c/p\u003e\n\u003cp\u003e✔️ Free for small usage (given my current revenue = zero)\u003cbr\u003e\n✔️ Easy integration with my stack existing stack (Python backend, Posthog for analytics)\u003cbr\u003e\n✔️ Scalable once I need more\u003c/p\u003e","title":"Klaviyo: Much more than a Shopify plug-in"},{"content":"As I mentioned in In-place DOM manipulation: Thorny as ever, text selection in browsers can get tricky fast. For poketto.me, the challenge was even bigger: the web app runs inside a hosted WebView in an Android app.\nMobile text selection already behaves differently from desktop, and the embedding scenario didn\u0026rsquo;t make things easier. After wrestling with a few awkward bugs, though, I found an elegant solution.\nOn the web, when a user finishes selecting text, poketto.me immediately converts the selection into a highlight (default color). On Android, however, a tiny system context menu pops up with entries like Copy, Select All, Web Search, etc. At first this got in the way\u0026mdash;but it turns out you can customize this menu on a per-activity basis!\nAll you need to do is override onActionModeStarted(...) and add/remove/reshuffle items as needed:\n@Override public void onActionModeStarted(ActionMode mode) { Menu menu = mode.getMenu(); MenuItem highlight = mode.getMenu().add(Menu.NONE, R.id.highlight_menu_item, 0, \\\u0026#34;Highlight\\\u0026#34;); highlight.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); highlight.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() { @Override public boolean onMenuItemClick(@NonNull MenuItem item) { MainActivity.this.evaluateJavascript(\\\u0026#34;window.dispatchEvent(new Event(\\\u0026#39;androidHighlight\\\u0026#39;))\\\u0026#34;); return false; } }); mode.hide(1); // more on this below 👇 super.onActionModeStarted(mode); } In poketto.me\u0026rsquo;s main activity, I injected a \u0026ldquo;Highlight\u0026rdquo; menu item that calls back into the web app, telling it to highlight the selected text. From then on, it works just like on the web.\n💡 PS: What\u0026rsquo;s mode.hide(1)? That\u0026rsquo;s one of those awkward bugs: without it, the menu doesn\u0026rsquo;t reliably work. Calling hide(1) tells Android to hide the menu for 1 millisecond, forcing a re-render\u0026mdash;including my custom menu item.\n","permalink":"https://build.ralphmayr.com/posts/84-you-can-customize-the-text-selection-menu-in-your-android-app/","summary":"\u003cp\u003eAs I mentioned in \n\u003ca href=\"../77-in-place-dom-manipulation-thorny-as-ever/\"\u003eIn-place DOM manipulation: Thorny as ever\u003c/a\u003e, text selection in browsers can get tricky fast. For poketto.me, the challenge was even bigger: the web app runs inside a hosted WebView in an Android app.\u003c/p\u003e\n\u003cp\u003eMobile text selection already behaves differently from desktop, and the embedding scenario didn\u0026rsquo;t make things easier. After wrestling with a few awkward bugs, though, I found an elegant solution.\u003c/p\u003e\n\u003cp\u003eOn the web, when a user finishes selecting text, poketto.me immediately converts the selection into a highlight (default color). On Android, however, a tiny \u003cstrong\u003esystem context menu\u003c/strong\u003e pops up with entries like \u003cem\u003eCopy, Select All, Web Search,\u003c/em\u003e etc. At first this got in the way\u0026mdash;but it turns out you can \u003cstrong\u003ecustomize this menu on a per-activity basis!\u003c/strong\u003e\u003c/p\u003e","title":"You can customize the text selection menu in your Android app"},{"content":"As I mentioned in Gemini\u0026rsquo;s URL Context feature is 90% hype, 10% value, I was pretty disappointed with Gemini\u0026rsquo;s \u0026ldquo;URL Context\u0026rdquo; feature. But \u0026ldquo;Video Understanding\u0026rdquo;? That one actually works like a charm.\nHow it works:\n👉 Provide a YouTube video link\n👉 Ask Gemini questions about the video\n👉 Get a structured response back\nFor poketto.me, this unlocks a really neat feature: users can save any YouTube video in the app and either watch it later or read a textual description of the video.\nHere\u0026rsquo;s the prompt I\u0026rsquo;m currently using:\n\u0026ldquo;Transcribe this video. Transcribe the spoken audio and verbally describe what the viewer would see. Provide your response in Markdown format. If applicable, use Markdown headings and subheadings to structure your response according to the individual sections of the video. Don\u0026rsquo;t include timestamps. Only respond with the transcription\u0026mdash;don\u0026rsquo;t repeat the prompt or add explanations.\u0026rdquo;\nExample: A full-length ZIB2 interview saved in poketto.me as a structured transcript: https://app.poketto.me/#/shared/MSVh8Q0\nTwo caveats:\n💰 Pricing: Right now, Google doesn\u0026rsquo;t charge input/output tokens for YouTube video processing. But eventually, they will.\n🤯 Consistency: Running the same video with the same prompt can yield very different results. Sometimes you\u0026rsquo;ll get speaker names formatted in Markdown; other times, just plain text. For my use case, that\u0026rsquo;s \u0026ldquo;good enough,\u0026rdquo; but for more serious tasks, this might be a no-go.\nStill, compared to my experience with URL Context, this feels like a big step forward.\n","permalink":"https://build.ralphmayr.com/posts/83-the-gemini-api-for-video-understanding-is-surprisingly-good/","summary":"\u003cp\u003eAs I mentioned in \n\u003ca href=\"../82-geminis-url-context-feature-is-90-hype-10-value/\"\u003eGemini\u0026rsquo;s URL Context feature is 90% hype, 10% value\u003c/a\u003e, I was pretty disappointed with Gemini\u0026rsquo;s \u0026ldquo;URL Context\u0026rdquo; feature. But \u0026ldquo;Video Understanding\u0026rdquo;? That one actually works like a charm.\u003c/p\u003e\n\u003cp\u003eHow it works:\u003c/p\u003e\n\u003cp\u003e👉 Provide a YouTube video link\u003cbr\u003e\n👉 Ask Gemini questions about the video\u003cbr\u003e\n👉 Get a structured response back\u003c/p\u003e\n\u003cp\u003eFor poketto.me, this unlocks a really neat feature: users can save any YouTube video in the app and either watch it later \u003cem\u003eor\u003c/em\u003e read a textual description of the video.\u003c/p\u003e","title":"The Gemini API for Video Understanding is surprisingly good"},{"content":"I\u0026rsquo;ll admit\u0026mdash;I was pretty excited when Google announced that the Gemini API would support a new \u0026ldquo;URL Context\u0026rdquo; tool. The idea: you could \u0026ldquo;ask\u0026rdquo; Gemini about the content of a specific web page, with Google handling all the heavy lifting.\nThe documentation even shows a neat example: send Gemini two recipe URLs and prompt it to compare ingredients and cooking times. If it worked, this would\u0026rsquo;ve been a game-changer for poketto.me:\n👉 No more scraping on my side\n👉 Built-in streamlining (and translating!) with a single API call\n👉 No extra interaction with another LLM (DeepSeek, in my case)\n👉 Native text extraction from PDFs\n👉 Maybe even a path to a simple \u0026ldquo;ChatPDF\u0026rdquo;-like feature?\nSo I spun it up, tested it\u0026mdash;and was immediately disappointed.\nNot even the official example worked. Gemini responded: \u0026ldquo;the second URL was not accessible.\u0026rdquo;\nI assumed maybe the site was down, or the publisher\u0026rsquo;s server got swamped by requests from other excited Gemini users. So I tried a simpler case: one URL + a prompt (\u0026ldquo;Extract all text\u0026hellip;\u0026rdquo;). Different error message, same outcome: Google couldn\u0026rsquo;t access the page.\nAcross ~20 test URLs, Gemini returned meaningful results for exactly two. And even for those, the response suffered from the typical LLM issues: They were padded with fluff (\u0026ldquo;Here\u0026rsquo;s the extracted text content\u0026hellip;\u0026rdquo;) that one would have to specifically engineer the prompt to leave out.\nA quick search led me to Google\u0026rsquo;s support forums\u0026mdash;full of similar complaints. The canned response: \u0026ldquo;Gemini won\u0026rsquo;t scrape paywalled content.\u0026rdquo; But in my tests (and many others), paywalls weren\u0026rsquo;t the issue at all.\nBottom line: the feature feels like yet another half-baked, prematurely launched AI tool. Poorly tested, inconsistent, and with almost no error handling. Caveat emptor.\nThat said, one silver lining: I also tried Gemini\u0026rsquo;s video understanding feature, and that actually works pretty well\u0026mdash;especially with YouTube videos. More on that tomorrow!\n","permalink":"https://build.ralphmayr.com/posts/82-geminis-url-context-feature-is-90-hype-10-value/","summary":"\u003cp\u003eI\u0026rsquo;ll admit\u0026mdash;I was pretty excited when Google announced that the Gemini API would support a new \u0026ldquo;URL Context\u0026rdquo; tool. The idea: you could \u0026ldquo;ask\u0026rdquo; Gemini about the content of a specific web page, with Google handling all the heavy lifting.\u003c/p\u003e\n\u003cp\u003eThe \n\u003ca href=\"https://www.youtube.com/watch?v=4-6WQl-Hls0\" target=\"_blank\" rel=\"noopener noreferrer\"\u003edocumentation\u003c/a\u003e even shows a neat example: send Gemini two recipe URLs and prompt it to compare ingredients and cooking times. If it worked, this would\u0026rsquo;ve been a game-changer for poketto.me:\u003c/p\u003e","title":"Gemini’s “URL Context” feature is 90% hype, 10% value"},{"content":"For the last 91 days, I\u0026rsquo;ve posted one of these \u0026ldquo;things I learned when building poketto.me\u0026rdquo; every day here on LinkedIn. What was my motivation for that?\n1️⃣ To reflect more deeply on the countless things I\u0026rsquo;ve learned. Think of it like a gratitude journal: by writing down the small technical quirks, process hacks, and organizational lessons, I hoped to make them stick better.\n2️⃣ To help others avoid some of the many mistakes I've made.\n3️⃣ To share \u0026ldquo;behind the scenes\u0026rdquo; work publicly\u0026mdash;so I might get feedback and new ideas from people beyond my inner circle.\n4️⃣ To grow my audience and connect with like-minded coders, builders, readers, writers, and tinkerers.\nGiven that this series of 100 posts will end in a bit over a week, let\u0026rsquo;s recap: How did it go?\n✅ On point #1: Excellent.\n❌ On #2, #3 and #4: Not so much.\nImpressions on my posts have steadily declined over the past two months, no matter what I tried:\n🔗 Removing external links → no change\n📸 More images → no change\n🎦 Adding video → no change\n📏 Shorter, snackable content → no change\n📃 Longer, thoughtful pieces → no change\n🕰️ Publish at different times of day → no change\n⚙️ Technical/code-heavy posts → no change\n👔 GTM/organizational topics → no change\nAnd the list goes on.\nWith ~500 connections and ~700 followers, I\u0026rsquo;d expect at least modest reach. But impressions in the lower double digits\u0026mdash;meaning that fewer than 10% of my followers even seeing my posts\u0026mdash;makes me wonder what exactly LinkedIn\u0026rsquo;s algorithm is optimizing for.\nJudging by my own LinkedIn feed, I\u0026rsquo;d say it's a combination of #clickbait garbage (\u0026ldquo;RIP McKinsey, GPT-5 just automated everything!\u0026rdquo;), ads (\u0026ldquo;Buy a $ 3,000 ticket to our conference!\u0026rdquo;), and pointless corporate updates (\u0026ldquo;I\u0026rsquo;m so proud that the company I\u0026rsquo;ve been with for three months is listed as a leader in Gartner\u0026rsquo;s magic quadrant for the 17th year in a row!\u0026rdquo;).\nStill, I\u0026rsquo;m taking a Stoic (capital-S) approach to this: I\u0026rsquo;ll finish the 100 posts I promised myself I\u0026rsquo;d write. Whether people see them or not is outside my control. What is in my control is where I choose to publish in the future.\n","permalink":"https://build.ralphmayr.com/posts/81-linkedin-isnt-working/","summary":"\u003cp\u003eFor the last 91 days, I\u0026rsquo;ve posted one of these \u0026ldquo;things I learned when building poketto.me\u0026rdquo; every day here on LinkedIn. What was my motivation for that?\u003c/p\u003e\n\u003cp\u003e1️⃣ To reflect more deeply on the countless things I\u0026rsquo;ve learned. Think of it like a gratitude journal: by writing down the small technical quirks, process hacks, and organizational lessons, I hoped to make them stick better.\u003c/p\u003e\n\u003cp\u003e2️⃣ To help others avoid some of the many mistakes I've made.\u003c/p\u003e","title":"LinkedIn isn’t working"},{"content":"\u0026hellip;and ultimately, that \u0026ldquo;someone\u0026rdquo; is going to send you a real invoice for actual money. In my case, I\u0026rsquo;m running poketto.me almost entirely on Google Cloud. While it\u0026rsquo;s not terribly expensive right now, it is a cost I had to factor into my pricing strategy.\nBut here\u0026rsquo;s the good news: Google offers various programs to support early-stage startups! Check out https://cloud.google.com/startup?hl=en for details.\nToday, I\u0026rsquo;m happy to share that poketto.me made it into the \u0026ldquo;START\u0026rdquo; tier of that program, which means: no Google Cloud bills in my mailbox for the foreseeable future!\nOf course, there\u0026rsquo;s no such thing as a free lunch, so here are the caveats:\n👉 The application process is truly Kafkaesque\u0026mdash;so much so that getting your app into the Play Store (see TIL #99) feels like a walk in the park by comparison. I won\u0026rsquo;t go into details, but trust me, it can be nerve-wracking.\n👉 The big bucks don\u0026rsquo;t flow freely. For poketto.me, being in the smallest tier, we\u0026rsquo;re talking about $2,000 for general services and about $800 for the mysterious \u0026ldquo;GenAI Builder\u0026rdquo; (whatever that actually is 😅).\n👉 Finally\u0026mdash;and this is why Google offers this in the first place\u0026mdash;you\u0026rsquo;re inevitably locking yourself into GCP. Architectural decisions I\u0026rsquo;m facing now (which tech stack to use for full-text search, whether to migrate to a proper database system, how to handle TTS, whether to keep using DeepSeek AI or switch to Gemini\u0026hellip;) are now influenced by the fact that \u0026ldquo;If I use Google\u0026rsquo;s solution, it\u0026rsquo;s free\u0026mdash;for now.\u0026rdquo; And as soon as my usage grows, Google will, of course, get their money back 🙃.\nNevertheless, I\u0026rsquo;m excited about this\u0026mdash;and maybe just a little bit proud that poketto.me has grown into something even Google recognizes as a startup!\n","permalink":"https://build.ralphmayr.com/posts/80-there-is-no-cloud-its-just-somebody-elses-computer/","summary":"\u003cp\u003e\u0026hellip;and ultimately, that \u0026ldquo;someone\u0026rdquo; is going to send you a real invoice for actual money. In my case, I\u0026rsquo;m running poketto.me almost entirely on Google Cloud. While it\u0026rsquo;s not terribly expensive right now, it is a cost I had to factor into my pricing strategy.\u003c/p\u003e\n\u003cp\u003eBut here\u0026rsquo;s the good news: Google offers various programs to support early-stage startups! Check out \n\u003ca href=\"https://cloud.google.com/startup?hl=en\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehttps://cloud.google.com/startup?hl=en\u003c/a\u003e for details.\u003c/p\u003e\n\u003cp\u003eToday, I\u0026rsquo;m happy to share that poketto.me made it into the \u0026ldquo;START\u0026rdquo; tier of that program, which means: no Google Cloud bills in my mailbox for the foreseeable future!\u003c/p\u003e","title":"There is no cloud (it’s just somebody else’s computer)"},{"content":"Since early August, I\u0026rsquo;d been toying with the idea of taking poketto.me \u0026ldquo;out of beta.\u0026rdquo; But with travel planned for late September through mid-October, timing became critical. I needed to launch before leaving, so I set \u0026ldquo;early September\u0026rdquo; as the latest possible date\u0026mdash;giving myself at least two weeks to handle any post-launch chaos.\nThe first question I asked: What does \u0026ldquo;launch\u0026rdquo; actually mean? What\u0026rsquo;s different afterwards?\nHere\u0026rsquo;s what came to mind:\n👉 The \u0026ldquo;beta\u0026rdquo; tag is removed everywhere (product, website, app store listings)\n👉 Save\u0026ndash;tag\u0026ndash;read works flawlessly (all beta-reported issues resolved)\n👉 The \u0026ldquo;highlights\u0026rdquo; feature works on both web and mobile\n👉 The \u0026ldquo;personal podcasts\u0026rdquo; feature is available to all users\n👉 The Android app is live in the Play Store (partly outside my control\u0026mdash;Google can be slow here)\n🤨 But.. what about the News Feeds feature, about fulltext search, about an iOS app?\nWorking backwards from this revealed a long list of tasks:\n👉 Implement all missing features\n👉 Enforce quotas and usage restrictions for personal podcasts (see TIL #88)\n👉 Give users a simple upgrade path when they hit those limits\n👉 Finalize pricing (TIL #88) and publish it both on the website and in the product\nThen came communication tasks:\n👉 Email all beta participants (ideally with a video + an incentive to try new features)\n👉 Overhaul the website\u0026ndash;but to what degree?\n👉 Generate some buzz\u0026mdash;ideally get influencers, bloggers, or journalists to cover the launch\nI mapped all of these (and more) into a preliminary launch checklist. Quickly, I realized I wouldn\u0026rsquo;t be able to do it all\u0026mdash;so I had to prioritize.\nMy method: a simple RAG scale.\n🟢 Green = must-have for a no-fuss launch. Definitely doable for early September.\n🟡 Amber = nice-to-have, if time allows\n🔴 Red = cool ideas, but not happening this time\nThat gave me a clear, focused plan to launch on time\u0026mdash;without losing sleep over the rest.\n#BuildInPublic #GTM #Launch #Checklist\n","permalink":"https://build.ralphmayr.com/posts/79-how-to-plan-a-time-based-launch/","summary":"\u003cp\u003eSince early August, I\u0026rsquo;d been toying with the idea of taking poketto.me \u0026ldquo;out of beta.\u0026rdquo; But with travel planned for late September through mid-October, timing became critical. I needed to launch before leaving, so I set \u0026ldquo;early September\u0026rdquo; as the latest possible date\u0026mdash;giving myself at least two weeks to handle any post-launch chaos.\u003c/p\u003e\n\u003cp\u003eThe first question I asked: What does \u0026ldquo;launch\u0026rdquo; actually mean? What\u0026rsquo;s different afterwards?\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s what came to mind:\u003c/p\u003e","title":"How to plan a time-based launch 🚀"},{"content":"Finding the right price point\u0026mdash;for anything\u0026mdash;is part science, part art, part alchemy\u0026hellip; and maybe a sprinkle of luck.\nCharge too little, and you leave money on the table. Charge too much, and you don\u0026rsquo;t close the deal. This trade-off is as old as commerce itself, but it\u0026rsquo;s especially tricky for intangible products like software\u0026mdash;particularly when selling subscriptions instead of one-offs and purely product-led (without the benefit of a human sales manager in the loop).\nFor poketto.me, I built a multi-layered pricing model. Here\u0026rsquo;s the process I followed:\n⬆️I started \u0026quot;bottom up,\u0026quot; first establishing my overhead costs. What does it cost me per month to operate the service? What do I pay for cloud infrastructure, web hosting, LLMs, etc.? Fortunately, the save-tag-read use case has a fairly linear cost structure. Regardless of the number of users (within reason), these costs remain fairly consistent.\n🔂 Variable costs. The personal podcast feature is trickier\u0026mdash;text-to-speech is GPU-intensive and costs me per minute. So I mapped low/medium/high usage scenarios for each plan (free, premium, unlimited) and calculated the average per-user cost. This helped me determine both usage caps and the minimum price I\u0026rsquo;d need to break even.\n↩️ Free user overhead. I then estimated the text-to-speech costs from small/medium/large cohorts of free users. Since free users create real costs, they must be offset by premium and unlimited subscribers. Unlike fixed infra, this overhead varies heavily with usage.\n↔️ Profit margins + scenarios. Next, I modeled per-user and overall profit margins across scenarios: stagnation, growth, hypergrowth. How many free users would I need to convert? How much revenue can I trade for annual commitments? What\u0026rsquo;s my margin for discounts or future affiliate marketing?\n🔃 Market check. Finally, I benchmarked my preliminary prices against similar subscriptions. A pure value-based approach didn\u0026rsquo;t make sense here, but I wanted to sit between \u0026ldquo;buy-me-a-coffee\u0026rdquo; Patreon-style creators and Netflix\u0026rsquo;s mid-tier plan. With all the above, that\u0026rsquo;s exactly where I landed.\nFor now\u0026mdash;and in combination with how I structured features, usage caps, and upgrade incentives (see TIL #85)\u0026mdash;I\u0026rsquo;m happy with the model. But it\u0026rsquo;s still early days, and I\u0026rsquo;ll have to see how it resonates with the market.\n","permalink":"https://build.ralphmayr.com/posts/78-pricing-art-science-alchemy/","summary":"\u003cp\u003eFinding the right price point\u0026mdash;for anything\u0026mdash;is part science, part art, part alchemy\u0026hellip; and maybe a sprinkle of luck.\u003c/p\u003e\n\u003cp\u003eCharge too little, and you leave money on the table. Charge too much, and you don\u0026rsquo;t close the deal. This trade-off is as old as commerce itself, but it\u0026rsquo;s especially tricky for intangible products like software\u0026mdash;particularly when selling subscriptions instead of one-offs and purely product-led (without the benefit of a human sales manager in the loop).\u003c/p\u003e","title":"Pricing: 🎨 Art + 🧪 Science + 🪄 Alchemy"},{"content":"The \u0026ldquo;highlights\u0026rdquo; feature in the poketto.me reader looks deceptively simple: select a text range, and it gets highlighted in yellow. Clicking a highlight opens a small context menu to change its color or delete it.\nBut the implementation? Much thornier than you\u0026rsquo;d think. Why?\n1️⃣ Browser selections are relative to visible text, not the underlying DOM tree of the rendered HTML.\n2️⃣ A selection is defined by the text plus its start offset (characters before the selection) and end offset.\n3️⃣ To do anything useful with a selection (beyond copying text to the clipboard), you have to map it back to its position in the DOM tree.\n4️⃣ That \u0026ldquo;mapping\u0026rdquo; is, to some extent, impossible to get perfectly right. A selection can span multiple nodes, start and end at different levels of depth, and include irrelevant nodes.\nTake this as an example:\nIf a user selects \u0026ldquo;among the great apes,\u0026rdquo; the selection includes text, an \u0026lt;a\u0026hellip;\u0026gt; link tag, and the comma belonging to the parent node.\nMy naïve approach to highlighting? Wrap the selection in a \u0026lt;span\u0026gt; with a highlight CSS class. Done.\nThe problem: inserting that \u0026lt;span\u0026gt; in the right spot is nearly impossible. And in more complex cases (e.g., selections including an image, headline, or whole paragraph), it completely breaks.\nThe \u0026ldquo;perfect\u0026rdquo; solution would be to split the selection into manageable parts, wrap each in its own \u0026lt;span\u0026gt;, and somehow tie them together logically. My interim solution is less elegant but handles ~95% of cases: if a selection spans multiple DOM elements, I truncate it at the end of the first one.\nSo in the example, the highlight would only cover \u0026ldquo;among the\u0026rdquo; and skip \u0026ldquo;great apes,\u0026rdquo;. Perfect? Not at all. But: Done is better than perfect (see Know When to Maximize and When to Satisfice) and in some cases, satisficing (see TIL #55) allows you to move on to new problems.\n","permalink":"https://build.ralphmayr.com/posts/77-in-place-dom-manipulation-thorny-as-ever/","summary":"\u003cp\u003eThe \u0026ldquo;highlights\u0026rdquo; feature in the poketto.me reader looks deceptively simple: select a text range, and it gets highlighted in yellow. Clicking a highlight opens a small context menu to change its color or delete it.\u003c/p\u003e\n\u003cp\u003eBut the implementation? Much thornier than you\u0026rsquo;d think. Why?\u003c/p\u003e\n\u003cp\u003e1️⃣ Browser selections are relative to visible text, not the underlying DOM tree of the rendered HTML.\u003cbr\u003e\n2️⃣ A selection is defined by the text plus its start offset (characters before the selection) and end offset.\u003cbr\u003e\n3️⃣ To do anything useful with a selection (beyond copying text to the clipboard), you have to map it back to its position in the DOM tree.\u003cbr\u003e\n4️⃣ That \u0026ldquo;mapping\u0026rdquo; is, to some extent, impossible to get perfectly right. A selection can span multiple nodes, start and end at different levels of depth, and include irrelevant nodes.\u003c/p\u003e","title":"In-place DOM manipulation: Thorny as ever 🥀"},{"content":"Since adopting Posthog for user analytics at poketto.me, I\u0026rsquo;ve grown pretty fond of the tool. Beyond the basics and advanced insights, I\u0026rsquo;m now also using it for feature flagging.\nFine-grained control (and easy rollback) of new features or major changes is becoming increasingly important as the poketto.me user base grows. Why?\n🤕 In a B2C app, you can\u0026rsquo;t count on users to complain when something breaks\u0026mdash;they\u0026rsquo;ll just leave. Especially for early-stage products, every irritated user is a missed opportunity.\n🧪 Having a small group of early adopters to test new features is incredibly useful. But you don\u0026rsquo;t want to manage access manually in the code or via a clunky, home-grown interface.\n🎉 Launching new features is now a bigger, coordinated effort: update the website, write an announcement email, post on Bluesky and X\u0026hellip; oh, and actually make the feature available 😅\nAs I shared in TIL #81, my first attempt at feature flagging used \u0026ldquo;entitlements\u0026rdquo; as a catch-all: a simple list of strings per user that I managed in an admin UI. (\u0026ldquo;Is this user allowed to see/do that?\u0026rdquo;) The fact that this wasn\u0026rsquo;t sustainable quickly made itself evident when I tried to untangle subscriptions, entitlements, pricing plans, and feature flags.\nSure, there are specialized tools like LaunchDarkly, Flagsmith, and many others. But for the size and scope of poketto.me, the fewer tools I need to manage, the better.\nWith Posthog, I can:\n👉 Check \u0026ldquo;feature X enabled or not?\u0026rdquo; with a single line of code\n👉 Toggle features on/off in the same environment I already use for analytics\n👉 Manage fine-grained rollouts (e.g., by country, browser, or platform\u0026mdash;like only Android users)\nThe only downside: Posthog doesn\u0026rsquo;t directly support the OpenFeature API (yet), which means you can\u0026rsquo;t easily swap it out for another feature flag provider later. There are a few projects out there aiming to help you with that, such as the Posthog Provider for OpenFeature for Node, but nothing that works across languages\u0026mdash;so far.\n","permalink":"https://build.ralphmayr.com/posts/76-posthog-also-works-well-for-feature-flagging/","summary":"\u003cp\u003eSince adopting \n\u003ca href=\"https://posthog.com/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ePosthog\u003c/a\u003e for user analytics at poketto.me, I\u0026rsquo;ve grown pretty fond of the tool. Beyond the \n\u003ca href=\"../68-product-analytics-posthog-is-my-tool-of-choice/\"\u003ebasics\u003c/a\u003e and \n\u003ca href=\"../72-product-analytics-is-more-than-dau-and-wau/\"\u003eadvanced insights\u003c/a\u003e, I\u0026rsquo;m now also using it for feature flagging.\u003c/p\u003e\n\u003cp\u003eFine-grained control (and easy rollback) of new features or major changes is becoming increasingly important as the poketto.me user base grows. Why?\u003c/p\u003e\n\u003cp\u003e🤕 In a B2C app, you can\u0026rsquo;t count on users to complain when something breaks\u0026mdash;they\u0026rsquo;ll just leave. Especially for early-stage products, every irritated user is a missed opportunity.\u003c/p\u003e","title":"Posthog also works well for feature flagging"},{"content":"Let\u0026rsquo;s face it: It\u0026rsquo;s hard to get a freemium model right. While thinking through pricing and packaging for poketto.me, I looked at a lot of other B2C apps\u0026mdash;and most of them had some flaw, inconsistency, or irritation in their approach.\nOne striking example is Strava. The fitness app is wildly popular (150M+ users worldwide) and valued at $2.2B. But their free-to-paid conversion strategy seems to be struggling. Why?\n👉 The core value (activity tracking) is fully commoditized, with little room to differentiate (Garmin Connect, Nike Run Club, Apple Health, etc. essentially all do the same thing).\n👉The network effect (\u0026ldquo;All my friends are there!\u0026rdquo;) is a lot smaller than with social networks. Surely nothing that Strava can be the house on.\n👉 Their differentiating features (custom routes, \u0026ldquo;AI Coach,\u0026rdquo; etc.) are binary: they either go into free or into premium.\n👉 The dilemma? If they add new features to free, there\u0026rsquo;s no incentive to upgrade. If they lock them behind premium, most free users never even know what they\u0026rsquo;re missing.\nTheir workaround has been to push free users into \u0026ldquo;free trials\u0026rdquo; of premium \u0026mdash; even auto-upgrading accounts for a month to showcase paid features. But: consumers, including yours truly, are skeptical. Will the subscription auto-cancel? Do I even want to invest the time to explore these extra features? The whole approach feels clunky and a bit needy.\nFor poketto.me, I\u0026rsquo;m trying to follow a different philosophy: features that are inherently binary (like highlighting, search, or advanced organization) will, by and large, remain part of the free plan. The real differentiators for premium will be usage-based, with clear limits for free users.\nThe podcast functionality is the clearest example: free users can create one podcast feed and have a cap on their monthly text-to-speech minutes. This way, they can experience the feature in full \u0026mdash; and, ideally, upgrade to premium not because they\u0026rsquo;re locked out, but because they want to use it more.\n","permalink":"https://build.ralphmayr.com/posts/75-the-freemium-trap-or-why-free-trials-dont-work/","summary":"\u003cp\u003eLet\u0026rsquo;s face it: It\u0026rsquo;s hard to get a freemium model right. While thinking through pricing and packaging for poketto.me, I looked at a lot of other B2C apps\u0026mdash;and most of them had some flaw, inconsistency, or irritation in their approach.\u003c/p\u003e\n\u003cp\u003eOne striking example is \n\u003ca href=\"https://strava.com\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eStrava\u003c/a\u003e. The fitness app is wildly popular (150M+ users worldwide) and valued at $2.2B. But their free-to-paid conversion strategy seems to be struggling. Why?\u003c/p\u003e\n\u003cp\u003e👉 The core value (activity tracking) is fully commoditized, with little room to differentiate (Garmin Connect, Nike Run Club, Apple Health, etc. essentially all do the same thing).\u003c/p\u003e","title":"The freemium trap (or why free trials don’t work)"},{"content":"A poketto.me Firefox user recently pointed out that Firefox on Android also supports browser extensions\u0026ndash; but that the poketto.me extension didn\u0026rsquo;t appear on Mozilla\u0026rsquo;s Add-Ons page there.\nTurns out: Any modern browser extension built on the standard APIs (Chrome, Firefox, Edge) can also run in Firefox on Android. But when publishing, you have to explicitly test and target Android.\nTechnically, the extension \u0026ldquo;just works\u0026rdquo;\u0026mdash;as long as you\u0026rsquo;re not doing anything exotic with the APIs. But aesthetically, there\u0026rsquo;s one big caveat.\n👉 The \u0026ldquo;popup\u0026rdquo; (the little dialog that opens when you click the extension icon in the toolbar) behaves differently:\nOn desktop, you define its size via CSS (width and height on the \u0026lt;body\u0026gt;), and the browser resizes accordingly.\nOn Firefox Android, the popup isn\u0026rsquo;t a popup at all \u0026mdash; it\u0026rsquo;s rendered as a full browser tab. If you\u0026rsquo;ve set fixed dimensions (say, 300px), the layout gets messed up (see Exhibit A).\nFor now, given the relatively small user base on Android, I\u0026rsquo;ll accept that the popup isn\u0026rsquo;t as polished as I\u0026rsquo;d like.\nThe \u0026ldquo;right\u0026rdquo; fix would be to ship a dedicated Android version without fixed dimensions. The \u0026ldquo;perfect\u0026rdquo; fix? An API (JavaScript or CSS media query) that tells the extension whether it\u0026rsquo;s running in a true popup vs. a full tab. Sadly, that doesn\u0026rsquo;t exist (yet).\n","permalink":"https://build.ralphmayr.com/posts/74-firefox-extensions-mostly-work-on-firefox-on-android-as-well/","summary":"\u003cp\u003eA poketto.me Firefox user recently pointed out that Firefox on Android also supports browser extensions\u0026ndash; but that the poketto.me extension didn\u0026rsquo;t appear on Mozilla\u0026rsquo;s Add-Ons page there.\u003c/p\u003e\n\u003cp\u003eTurns out: Any modern browser extension built on the standard APIs (Chrome, Firefox, Edge) can also run in Firefox on Android. But when publishing, you have to explicitly test and target Android.\u003c/p\u003e\n\u003cp\u003eTechnically, the extension \u0026ldquo;just works\u0026rdquo;\u0026mdash;as long as you\u0026rsquo;re not doing anything exotic with the APIs. But aesthetically, there\u0026rsquo;s one big caveat.\u003c/p\u003e","title":"Firefox extensions (mostly) work on Firefox on Android as well"},{"content":"A poketto.me user recently filed a curious bug: They had saved a page that clearly contained images \u0026mdash; but in the reader view, no images showed up.\nI expected some quirky HTML. But when I checked, the \u0026lt;img\u0026gt; tags looked perfectly normal (see Exhibit A). Yet, after passing the HTML through trafilatura (which I use to convert HTML to Markdown), the images had simply vanished.\n🔎 The culprit? Trafilatura is very cautious with images. It only accepts \u0026lt;img src=...\u0026gt; URLs that end with a known extension (.jpg, .png, .gif, \u0026hellip;). The site in question served images from URLs without extensions \u0026mdash; so trafilatura just ignored them.\n👉 My quick fix: If poketto.me encounters an \u0026lt;img\u0026gt; without a \u0026ldquo;valid\u0026rdquo; extension, I tack one on artificially so trafilatura accepts it. Hacky, but it worked \u0026mdash; not only for that user\u0026rsquo;s page but for many more sites.\nThe proper fix would be more expensive:\n1️⃣Trafilatura would need to send an HTTP HEAD request for each image.\n2️⃣If the Content-Type starts with image/, accept it \u0026mdash; regardless of the URL. If not, ignore it.\nThat\u0026rsquo;s the robust solution. But I also understand why trafilatura avoids it: dozens of HEAD requests could slow content extraction to a crawl.\nSo here\u0026rsquo;s my plan on how to help the folks behind trafilatura out:\n🔵I\u0026rsquo;ll draft one pull request that introduces the HEAD-check as an opt-in option.\n🔵I\u0026rsquo;ll draft another PR that simply disables the sanity check (closer to my current workaround), also as an opt-in option.\nLet\u0026rsquo;s see which one gets traction with the maintainers.\nSometimes \u0026ldquo;protective\u0026rdquo; defaults make sense\u0026hellip; until they eat your users\u0026rsquo; images. 😅\n","permalink":"https://build.ralphmayr.com/posts/73-trafilaturas-image-extraction-is-a-bit-too-cautious-for-my-taste/","summary":"\u003cp\u003eA poketto.me user recently filed a curious bug: They had saved a page that clearly contained images \u0026mdash; but in the reader view, no images showed up.\u003c/p\u003e\n\u003cp\u003eI expected some quirky HTML. But when I checked, the \u0026lt;img\u0026gt; tags looked perfectly normal (see Exhibit A). Yet, after passing the HTML through trafilatura (which I use to convert HTML to Markdown), the images had simply vanished.\u003c/p\u003e\n\u003cp\u003e🔎 The culprit? Trafilatura is very cautious with images. It only accepts \u0026lt;img src=...\u0026gt; URLs that end with a known extension (.jpg, .png, .gif, \u0026hellip;). The site in question served images from URLs without extensions \u0026mdash; so trafilatura just ignored them.\u003c/p\u003e","title":"trafilatura’s image extraction is a bit too cautious for my taste"},{"content":" Recently, I wrote about adopting Posthog for poketto.me. At first, I thought I\u0026rsquo;d use it for the basics:\n📆 Daily \u0026amp; weekly active users (DAU/WAU)\n📎 Core events (URLs saved, links shared, etc.)\n🚨 Error tracking and alerting\nBut then I realized: analytics can do much more. In fact, Posthog replaced one of my home-grown tools \u0026mdash; my \u0026ldquo;podcast heuristic accuracy guestimator.\u0026rdquo; Let me explain.\nWhen a user adds content to their podcast feed, poketto.me has to gauge three things:\na) How long will it take to generate the episode? (So that I can give nice, visual feedback as discussed in TIL #68)\nb) How long will the episode actually play?\nc) Will the episode be truncated by the user\u0026rsquo;s quota (see TIL #81)?\nMy early heuristics looked like this:\n⏱ ~0.33 seconds to generate each spoken word\n⏱ ~0.5 seconds of audio per word\n📄 Script length ≈ 90% of original text length\nHow did I measure that? With the most primitive tool of all: printf logging. Every test run, I scraped CloudRun logs, pasted numbers into a spreadsheet, recalculated factors, and updated my code. It worked \u0026mdash; but was clunky and error-prone.\nEnter Posthog 🦔\nNow I track three events for each podcast episode:\n🔵save_added_to_podcast_feed → captures raw word count + estimated duration\n🔵start_processing_podcast_episode → captures the TTS-optimized script length\n🔵finished_processing_podcast_episode → captures the actual duration\nWith these in Posthog\u0026rsquo;s data warehouse, I can easily query ratios, refine my heuristics, and spot divergences across languages or voice models. No manual log-scraping, no spreadsheet acrobatics.\nTurns out: product analytics isn\u0026rsquo;t just about counting active users. Sometimes it\u0026rsquo;s the perfect place to offload parts of your code itself to a more specialized tool.\n","permalink":"https://build.ralphmayr.com/posts/72-product-analytics-is-more-than-dau-and-wau/","summary":"\u003cp\u003e\n\u003ca href=\"../68-product-analytics-posthog-is-my-tool-of-choice/\"\u003eRecently\u003c/a\u003e, I wrote about adopting Posthog for poketto.me. At first, I thought I\u0026rsquo;d use it for the basics:\u003c/p\u003e\n\u003cp\u003e📆 Daily \u0026amp; weekly active users (DAU/WAU)\u003cbr\u003e\n📎 Core events (URLs saved, links shared, etc.)\u003cbr\u003e\n🚨 Error tracking and alerting\u003c/p\u003e\n\u003cp\u003eBut then I realized: analytics can do much more. In fact, Posthog replaced one of my home-grown tools \u0026mdash; my \u0026ldquo;podcast heuristic accuracy guestimator.\u0026rdquo; Let me explain.\u003c/p\u003e\n\u003cp\u003eWhen a user adds content to their podcast feed, poketto.me has to gauge three things:\u003c/p\u003e","title":"Product analytics is more than DAU and WAU"},{"content":"Early-stage products are all about uncertainty. With poketto.me, I started by building something I wanted to use \u0026mdash; and gave it away for free. Then came early adopters asking for features, and eventually I began experimenting with monetizable \u0026ldquo;premium\u0026rdquo; features: personalized podcasts, news aggregation, summaries, contextualization, etc.\nThat third bucket quickly gave me headaches. I needed a way to put usage guardrails around these features:\n👉 to separate free from premium,\n👉 to distinguish \u0026ldquo;beta\u0026rdquo; from production-ready,\n👉 and to keep my future monetization options open.\nFollowing my own advice from TIL #37 (don\u0026rsquo;t bring out the big guns right away), I skipped enterprise-grade entitlement systems like ChargeBee and hacked together something simple: at first, each user got a list of entitlements (just free-text strings) that my code would check and that I could manage in a simple admin UI. ✅\nThat worked\u0026mdash;until it didn\u0026rsquo;t.\nAs I refined the personalized podcasts, I realized a binary \u0026ldquo;Y/N\u0026rdquo; wasn\u0026rsquo;t enough. I wanted free users to create podcasts too, but with limits:\n👉number of feeds,\n👉max length per episode,\n👉total monthly listening time.\nSuddenly my simple check ballooned into quotas, both absolute (\u0026ldquo;feeds,\u0026rdquo; \u0026ldquo;episode length\u0026rdquo;) and replenishing (\u0026ldquo;minutes per month\u0026rdquo;). That meant I had to add billing periods, rollover handling, and grace logic (see Exhibit B).\nThen, launch loomed. Packaging deliberations led me to three plans: Free, Premium, and Unlimited. Now my model had to support \u0026ldquo;Plans\u0026rdquo; that defined user quotas (see Exhibit C).\nAnd of course, I still had to map all this to Stripe\u0026rsquo;s model (customers, prices, subscriptions). Not exactly plug-and-play. 😅\nI got it working in the end \u0026mdash; but in hindsight? Using a dedicated solution like RevenueCat (which integrates directly with Stripe) might have been the smarter path.\n","permalink":"https://build.ralphmayr.com/posts/71-entitlements-are-easy-until-theyre-not/","summary":"\u003cp\u003eEarly-stage products are all about uncertainty. With poketto.me, I started by building something I wanted to use \u0026mdash; and gave it away for free. Then came early adopters asking for features, and eventually I began experimenting with monetizable \u0026ldquo;premium\u0026rdquo; features: personalized podcasts, news aggregation, summaries, contextualization, etc.\u003c/p\u003e\n\u003cp\u003eThat third bucket quickly gave me headaches. I needed a way to put usage guardrails around these features:\u003c/p\u003e\n\u003cp\u003e👉 to separate free from premium,\u003cbr\u003e\n👉 to distinguish \u0026ldquo;beta\u0026rdquo; from production-ready,\u003cbr\u003e\n👉 and to keep my future monetization options open.\u003c/p\u003e","title":"Entitlements are easy (until they’re not)"},{"content":"This is a roundabout way of saying: make \u0026ldquo;good\u0026rdquo; behavior the easy choice. But there\u0026rsquo;s an interesting backstory to the saying.\nI come from a family of manual laborers. My dad, all of my uncles, and most of my many cousins work in trades ranging from carpentry to plumbing to house painting. And one of the first things any good craftsman does when setting up at a new site? Installing a dustbin.\nWhy? Because construction work inevitably creates garbage: empty rolls of tape, old brushes, wrapping paper, single-use gloves. And if there\u0026rsquo;s nowhere to put them, they\u0026rsquo;ll almost certainly end up on the floor \u0026mdash; where you\u0026rsquo;ll either trip over them, clean them up later, or worse, leave them littering the place.\nThe principle is well understood in behavioral science: humans are \u0026ldquo;lazy\u0026rdquo; by default. If there\u0026rsquo;s no dustbin in sight when you pull off your gloves and your mind is elsewhere, you\u0026rsquo;ll just drop them. But if there is a dustbin? Then the easy thing to do is also the right thing to do.\nThe same applies to software development and product work.\nExample from poketto.me: for the longest time, running and debugging the Android app was a hassle. I had to change the base URL in two config files. Then I had to start my frontend with a different command so the emulator could access it. Then I had to set up remote debugging of the WebView. Just thinking about it made me skip the task in favor of something \u0026ldquo;easier.\u0026rdquo;\nBut once I bundled all of that into a single shell script, I could run and debug the Android app with basically one click.\nThe result? I\u0026rsquo;m much more engaged in fixing Android bugs now \u0026mdash; because I made the right behavior the easy one.\n","permalink":"https://build.ralphmayr.com/posts/70-a-room-without-a-dustbin-will-never-be-clean/","summary":"\u003cp\u003eThis is a roundabout way of saying: make \u0026ldquo;good\u0026rdquo; behavior the easy choice. But there\u0026rsquo;s an interesting backstory to the saying.\u003c/p\u003e\n\u003cp\u003eI come from a family of manual laborers. My dad, all of my uncles, and most of my many cousins work in trades ranging from carpentry to plumbing to house painting. And one of the first things any good craftsman does when setting up at a new site? Installing a dustbin.\u003c/p\u003e","title":"A room without a dustbin will never be clean"},{"content":"I put off building checkout and payments for the premium version of poketto.me for way too long. The thought of integrating with Stripe \u0026mdash; keeping \u0026ldquo;their\u0026rdquo; and \u0026ldquo;my\u0026rdquo; data in sync, handling callbacks, connecting the UI, smoothing out user flows\u0026hellip; it always felt like too much to tackle on any given day.\nBut once I committed to a GA date for the premium version, I had to bite the bullet.\nTurns out: integrating with Stripe is still no walk in the park. You need to dive into their API, understand their data model, figure out how it maps to your own business logic, and write code to keep everything in sync. (More on that in a future TIL!)\nBut the testing side? Surprisingly convenient!\nExample: when a customer buys a subscription (via Stripe\u0026rsquo;s hosted checkout page), Stripe notifies me so I can mark them as a \u0026ldquo;premium customer.\u0026rdquo; In production, that happens via a webhook behind the scenes. But during development, I want to run my side of the transaction locally for debugging. Naturally, Stripe\u0026rsquo;s servers can\u0026rsquo;t just call my laptop directly.\nEnter: Stripe CLI 🎉\nOnce installed and connected to your account, you can tell it to forward Stripe server-side callbacks to your local machine. That way you can test and debug the whole flow as if it were happening in production \u0026mdash; but safely, on your laptop.\n","permalink":"https://build.ralphmayr.com/posts/69-debugging-your-stripe-integration-is-easy-with-stripe-cli/","summary":"\u003cp\u003eI put off building checkout and payments for the premium version of poketto.me for way too long. The thought of integrating with Stripe \u0026mdash; keeping \u0026ldquo;their\u0026rdquo; and \u0026ldquo;my\u0026rdquo; data in sync, handling callbacks, connecting the UI, smoothing out user flows\u0026hellip; it always felt like \u003cem\u003etoo much\u003c/em\u003e to tackle on any given day.\u003c/p\u003e\n\u003cp\u003eBut once I committed to a GA date for the premium version, I had to bite the bullet.\u003c/p\u003e","title":"Debugging your Stripe integration is easy with Stripe CLI"},{"content":"Event tracking and analytics is one of those cross-cutting topics I mentioned back in You don\u0026rsquo;t need to bring out the big guns right away (but it\u0026rsquo;s good to know them anyway): in the beginning, it doesn\u0026rsquo;t really matter if or how you do it. Often, you can just hack something together and move on to more important things. For the longest time, for example, poketto.me just had a hard-coded email notification that let me know whenever a new user signed up. That was enough to give me a general sense of what was going on.\nOf course, that\u0026rsquo;s not sustainable. So as things started to take off, I went looking for a simple, free-to-start tool that could grow with poketto.me if needed.\nLuckily, I remembered an in-depth evaluation my colleagues at smec had done a few months back. PostHog passed their requirements with flying colors \u0026mdash; so I tried it out for poketto.me. Turns out? It\u0026rsquo;s perfect.\n👉 Free to start with very generous (their term!) usage limits\n👉 Developer-focused and API-friendly\n👉 Built-in support for countless languages and frameworks, including Angular and Python (my stack)\nThese days, I use it not just for \u0026ldquo;vanity metrics\u0026rdquo; (how many users signed up, how many URLs were saved in a day, \u0026hellip;), but also for more insightful ones:\n➡️How many users keep using poketto.me daily / weekly?\n➡️Do we encounter errors fetching content from 3rd-party sites?\n➡️How accurate are my predictions about podcast generation time and episode length?\nDespite my usual caution against \u0026ldquo;all-in-one\u0026rdquo; tools, I\u0026rsquo;ve come to appreciate PostHog\u0026rsquo;s approach. I even adopted their feature-flagging system \u0026mdash; I didn\u0026rsquo;t want to add another tool just for a handful of use cases. And it integrates beautifully. Now, for example, I could roll out a new feature only to \u0026ldquo;everyone using Firefox,\u0026rdquo; or \u0026ldquo;those five users,\u0026rdquo; or \u0026ldquo;everyone except users in Vienna.\u0026rdquo; Pretty neat! 🚀\n","permalink":"https://build.ralphmayr.com/posts/68-product-analytics-posthog-is-my-tool-of-choice/","summary":"\u003cp\u003eEvent tracking and analytics is one of those cross-cutting topics I mentioned back in \n\u003ca href=\"../27-you-dont-need-to-bring-out-the-big-guns-right-away-but-its-good-to-know-them-anyway/\"\u003eYou don\u0026rsquo;t need to bring out the big guns right away (but it\u0026rsquo;s good to know them anyway)\u003c/a\u003e: in the beginning, it doesn\u0026rsquo;t really matter if or how you do it. Often, you can just hack something together and move on to more important things. For the longest time, for example, poketto.me just had a hard-coded email notification that let me know whenever a new user signed up. That was enough to give me a general sense of what was going on.\u003c/p\u003e","title":"Product analytics? PostHog is my tool of choice!"},{"content":"Working solo on poketto.me \u0026mdash; without the built-in \u0026ldquo;pressure to deliver\u0026rdquo; that comes with a corporate environment \u0026mdash; comes with an interesting challenge: actually shipping stuff.\nHere\u0026rsquo;s an example. When I started building the \u0026ldquo;Share\u0026hellip;\u0026rdquo; feature, I had something super simple in mind: users should be able to create shareable links to their content, which others could view on poketto.me without logging in. I figured this would create a self-reinforcing loop: existing users share content → new people enjoy the clean, distraction-free reading experience → they sign up → save their own content → share it → and the cycle continues.\nThe feature itself wasn\u0026rsquo;t that hard to build. A few security considerations for non-authenticated users, some workflow smoothing (e.g., letting users stop sharing or regenerate links), and that was it. Once I had that in place, I could have shipped it almost immediately.\nBut then I thought: wouldn\u0026rsquo;t it be even more compelling if users could share directly to Bluesky / Facebook / LinkedIn / X? So I built that too.\nWhich led me down the rabbit hole of: How do I get these platforms to generate nice previews of the shared content?\nMeanwhile, I also hacked together a working prototype of the \u0026ldquo;Highlights\u0026rdquo; feature \u0026mdash; letting users color-code text in their saved content. And of course I thought: wouldn\u0026rsquo;t it be even better if you could share your highlights too? And what about comments, annotations, even discussion threads? Before I knew it, I was staring at a myriad of possible add-ons.\nThe problem? I hadn\u0026rsquo;t even shipped the basic \u0026ldquo;Share\u0026hellip;\u0026rdquo; feature by that point.\nHere, I wrote about maximizing vs. satisficing. That\u0026rsquo;s one helpful model for thinking about getting things done \u0026mdash; but not the only one. In this case, I\u0026rsquo;d drifted into maximizing without realizing it, which led to delayed shipment, delayed feedback, and potentially wasted effort on \u0026ldquo;something nobody might even need.\u0026rdquo;\nWhat should I have done?\nBefore writing a single line of code, I should have sketched a mini roadmap with acceptance criteria for the smallest valuable increments. Ship the MVP first. Then iterate.\nAttached is a scribble of how that could have looked \u0026mdash; and how I plan to do it in the future.\n(And yes: if you rotate that scribble by 90 degrees, it does kinda look like a User Story Map 🚀)\n","permalink":"https://build.ralphmayr.com/posts/67-define-what-done-looks-like-and-stick-to-it/","summary":"\u003cp\u003eWorking solo on poketto.me \u0026mdash; without the built-in \u0026ldquo;pressure to deliver\u0026rdquo; that comes with a corporate environment \u0026mdash; comes with an interesting challenge: actually shipping stuff.\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s an example. When I started building the \u0026ldquo;Share\u0026hellip;\u0026rdquo; feature, I had something super simple in mind: users should be able to create shareable links to their content, which others could view on poketto.me without logging in. I figured this would create a self-reinforcing loop: existing users share content → new people enjoy the clean, distraction-free reading experience → they sign up → save their own content → share it → and the cycle continues.\u003c/p\u003e","title":"Define what ‘done’ looks like (and stick to it!)"},{"content":"The other week, I was building a neat little feature for the poketto.me browser extensions (Chrome, Firefox, and Edge): when you save a web page, the extension should capture not just the URL, but the entire content you\u0026rsquo;re looking at in that moment.\n🤨Why does this matter?\nIf you\u0026rsquo;re signed in on a page (say, with your New York Times subscription), poketto.me can capture the full article \u0026mdash; just as you, the paying subscriber, see it. Otherwise, poketto.me tries to fetch the article in the background\u0026hellip; but it will run straight into the paywall (see TIL #59).\nMy first instinct was to use a content script: inject JavaScript into the page to grab its content. After some fiddling, I got that to work well enough:\n📩 Extension side:\nconst \\[tab\\] = await chrome.tabs.query({ active: true, currentWindow: true }); chrome.tabs.sendMessage(tab.id, { text: \\\u0026#39;get_page_content\\\u0026#39; }, pageContentReceived); 📨 Content script side:\nchrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {\\ if (msg.text === \\\u0026#39;get_page_content\\\u0026#39;) {\\ const fullHTML = document.documentElement.outerHTML;\\ sendResponse(fullHTML);\\ }\\ }); The problem? To inject this script, the extension has to request permission for \u0026ldquo;any URL\u0026rdquo; in manifest.json. And if you try to submit that to the Chrome Web Store (or Mozilla), you get a big red warning. That triggers a manual review with an uncertain outcome.\nSo \u0026mdash; back to the drawing board.\nTurns out, there\u0026rsquo;s a simpler solution: no content script needed. You can run JavaScript in the active tab directly:\nconst executeScript = (tabId, func) =\\\u0026gt;\\ new Promise(resolve =\\\u0026gt; {\\ chrome.scripting.executeScript({ target: { tabId }, func }, resolve)\\ }) ","permalink":"https://build.ralphmayr.com/posts/66-your-browser-extension-doesnt-necessarily-need-that-many-permissions/","summary":"\u003cp\u003eThe other week, I was building a neat little feature for the poketto.me browser extensions (Chrome, Firefox, and Edge): when you save a web page, the extension should capture not just the URL, but the entire content you\u0026rsquo;re looking at in that moment.\u003c/p\u003e\n\u003cp\u003e🤨Why does this matter?\u003c/p\u003e\n\u003cp\u003eIf you\u0026rsquo;re signed in on a page (say, with your New York Times subscription), poketto.me can capture the full article \u0026mdash; just as you, the paying subscriber, see it. Otherwise, poketto.me tries to fetch the article in the background\u0026hellip; but it will run straight into the paywall (see TIL #59).\u003c/p\u003e","title":"Your browser extension doesn’t necessarily need that many permissions"},{"content":"I promise I won\u0026rsquo;t turn into my Strava feed. But there\u0026rsquo;s something to be said about the similarities between running long distances and building products.\nYou\u0026rsquo;ve probably heard the \u0026ldquo;it\u0026rsquo;s a marathon, not a sprint\u0026rdquo; metaphor before \u0026mdash; but I want to add one more twist.\nYou can approach running a marathon in very different ways. Some people go from zero-to-marathon in 12 weeks of intense training. It\u0026rsquo;s doable, but those gut-wrenching training plans\u0026hellip; you really don\u0026rsquo;t want to go through that. And often, this results in a once-in-a-lifetime, superhuman effort. It looks hard. It feels hard. Sure, you can pat yourself on the back afterwards if you make it \u0026mdash; but the risk of injury is high, the odds of repeating the feat are low, and the long-term benefits are minimal.\nMy approach \u0026mdash; to running and to getting good at anything (whether it\u0026rsquo;s athletics, writing, coding, building a business, or any worthwhile endeavor) \u0026mdash; is different: make the behavior you want to master a consistent habit, ideally with a built-in feedback loop. Do it over and over again. Stick with it. Improve over time.\nI started running about 10 years ago and have been consistent for the last five. By \u0026ldquo;consistent,\u0026rdquo; I mean: one run per day, more often than not. At least 5\u0026ndash;6 runs per week. Literally thousands of kilometers every year.\nWhat does that turn running a 51k ultra marathon into? Not a walk in the park, for sure. Not \u0026ldquo;totally easy.\u0026rdquo; But absolutely doable \u0026mdash; in a way that I could start the race confident I\u0026rsquo;d finish it\u0026hellip; and even do it again the week after. Almost, to borrow Greg McKeown\u0026rsquo;s beautiful term, effortless.\nWorking on poketto.me feels the same way most days. I add features, fix the occasional bug, do some go-to-market work, write a blog post, refine the commercial model, record a demo video, \u0026hellip; But the reason I can do these things in a way that looks \u0026ldquo;easy\u0026rdquo; from the outside is because of the years of prep work I\u0026rsquo;ve put in:\n5 years studying at a technical college Earning a BSc and MSc in Software Engineering while working full-time at Over 20 years of hands-on experience in the field I wouldn\u0026rsquo;t have liked to hear this when I was younger, but: it matters that you put in the effort, make the mistakes, and learn from them yourself. That\u0026rsquo;s what makes the hard things look \u0026mdash; and feel \u0026mdash; effortless.\nJohn L. Parker\u0026rsquo;s beautiful novel \u0026ldquo;Once a Runner\u0026rdquo; ends like this:\n\u0026ldquo;What was the secret, they wanted to know; in a thousand different ways they wanted to know The Secret. And not one of them was prepared, truly prepared to believe that it had not so much to do with chemicals and zippy mental tricks as with that most unprofound and sometimes heart-rending process of removing, molecule by molecule, the very tough rubber that comprised the bottoms of his training shoes. The Trial of Miles; Miles of Trials.\u0026rdquo;\n","permalink":"https://build.ralphmayr.com/posts/65-its-more-than-a-marathon/","summary":"\u003cp\u003eI promise I won\u0026rsquo;t turn into my Strava feed. But there\u0026rsquo;s something to be said about the similarities between running long distances and building products.\u003c/p\u003e\n\u003cp\u003eYou\u0026rsquo;ve probably heard the \u0026ldquo;it\u0026rsquo;s a marathon, not a sprint\u0026rdquo; metaphor before \u0026mdash; but I want to add one more twist.\u003c/p\u003e\n\u003cp\u003eYou can approach running a marathon in very different ways. Some people go from zero-to-marathon in 12 weeks of intense training. It\u0026rsquo;s doable, but those gut-wrenching training plans\u0026hellip; you really don\u0026rsquo;t want to go through that. And often, this results in a once-in-a-lifetime, superhuman effort. It looks hard. It feels hard. Sure, you can pat yourself on the back afterwards if you make it \u0026mdash; but the risk of injury is high, the odds of repeating the feat are low, and the long-term benefits are minimal.\u003c/p\u003e","title":"It’s more than a marathon"},{"content":"I physically winced when a beta tester of the poketto.me Android app reported this bug:\n\u0026ldquo;Sometimes, adding new tags to a Save doesn\u0026rsquo;t work. I open the tagging dialog, type a new tag, click \u0026lsquo;save\u0026rsquo; \u0026mdash; the input field clears, but the new tag is nowhere to be seen.\u0026rdquo;\nI was able to reproduce the issue on my phone and in an Android emulator.\nGut reaction: \u0026ldquo;Ah, must be the WebView. WebViews always behave differently than a normal browser. This will be a mess.\u0026rdquo;\nI already dreaded debugging it. (Yes, Chrome DevTools can inspect a WebView remotely over the air on a real device \u0026mdash; but it\u0026rsquo;s a hassle, trust me.)\nNext test: Same steps, but in Chrome on Android \u0026mdash; same bug.\nNew theory: \u0026ldquo;Okay, so Chrome on Android has the issue, not just WebView. That\u0026rsquo;s at least a bit easier to debug.\u0026rdquo;\nBefore going down that road, I \u0026ldquo;fixed\u0026rdquo; a few things in the tagging dialog (event propagation, layout tweaks, Angular component dependencies\u0026hellip;) and tested again.\nNo improvement.\nAfter too much back-and-forth, I finally realized my intuition was completely wrong. The bug had nothing to do with:\n➡️ WebViews\n➡️ Chrome on Android\n➡️ Or even mobile browsers in general\nIt was a boring, plain-old bug reproducible on desktop too:\nIf you added a \u0026ldquo;new\u0026rdquo; tag that matched an existing one except for case (e.g., \u0026ldquo;Ai\u0026rdquo; instead of \u0026ldquo;ai\u0026rdquo;), it silently failed.\nThe cause? A tiny oversight from when I first implemented tag \u0026ldquo;streamlining\u0026rdquo; (lowercasing tags, replacing spaces with hyphens, etc.). One part of the code did the normalization, another assumed it had already been done. So:\nCheck if tag exists? → Looks for Ai\nAdd new tag? → Converts to ai → Thinks it\u0026rsquo;s different\nAnd why was this reported on mobile? Turns out, the on-screen-keyboard in combination with autocorrect will uppercase the users\u0026rsquo;s input (if you don\u0026rsquo;t prevent that). Hence, tapping \u0026ldquo;a\u0026rdquo; and then \u0026ldquo;i\u0026rdquo; would enter \u0026ldquo;Ai\u0026rdquo;, thus triggering the bug.\nLesson learned: Always thoroughly test and reproduce reported bugs *before* a) sweating over something completely unrelated, and\nb) blaming someone else (Chrome, Android, the universe).\n","permalink":"https://build.ralphmayr.com/posts/64-always-suspect-your-own-code-first/","summary":"\u003cp\u003eI physically winced when a beta tester of the poketto.me Android app reported this bug:\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u0026ldquo;Sometimes, adding new tags to a Save doesn\u0026rsquo;t work. I open the tagging dialog, type a new tag, click \u0026lsquo;save\u0026rsquo; \u0026mdash; the input field clears, but the new tag is nowhere to be seen.\u0026rdquo;\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eI was able to reproduce the issue on my phone \u003cem\u003eand\u003c/em\u003e in an Android emulator.\u003cbr\u003e\nGut reaction: \u003cem\u003e\u0026ldquo;Ah, must be the WebView. WebViews always behave differently than a normal browser. This will be a mess.\u0026rdquo;\u003c/em\u003e\u003c/p\u003e","title":"Always suspect your own code first"},{"content":"Yesterday, I described my elegant solution to the social media \u0026ldquo;preview\u0026rdquo; problem: Direct the share URL to the Python backend, render a skeleton HTML page with machine-readable metadata, and send real browsers to the Angular app\u0026rsquo;s proper \u0026ldquo;read\u0026rdquo; page.\nBut\u0026hellip; how exactly do you redirect the user?\nMy first, naive idea was to use the age-old HTML redirect tag:\n\u0026lt;meta http-equiv=\u0026quot;refresh\u0026quot; content=\u0026quot;0; url=http://example.com/\u0026quot; \\\u0026gt;\nMy assumption: social media bots wouldn\u0026rsquo;t follow these \u0026ldquo;semantic\u0026rdquo; redirects, but they would follow HTTP redirects (which they definitely do). This worked perfectly \u0026mdash; except for LinkedIn 😅\nLinkedIn does parse the HTML, sees the meta refresh, ignores the rest of the metadata, follows the redirect to the Angular share page\u0026hellip; and then finds zero machine-readable information 🕵️‍♂️\nWhen I discovered this, I closed my laptop in frustration, slept on it, and \u0026mdash; during my morning run \u0026mdash; the solution popped into my head: Why not use an even more \u0026ldquo;semantic\u0026rdquo; redirect, one that only an actual browser would follow? Why not redirect with JavaScript?\nSo that\u0026rsquo;s what I did. My skeleton SoMe-optimized page now contains this in the \u0026lt;head\u0026gt;:\n\u0026lt;script\\\u0026gt; window.location.href = \\\u0026quot;REDIRECT_URL\\\u0026quot;; \u0026lt;/script\\\u0026gt;\nNormal browsers immediately jump to the Angular read page. Bots like LinkedIn\u0026rsquo;s? They don\u0026rsquo;t follow it.\nPS: LinkedIn has a handy \u0026ldquo;Post Inspector\u0026rdquo; tool that lets you preview how a link will look when shared, and see exactly where it fetches metadata: https://www.linkedin.com/post-inspector/\n","permalink":"https://build.ralphmayr.com/posts/63-social-media-previews-are-tricky-part-2/","summary":"\u003cp\u003eYesterday, I described my elegant solution to the social media \u0026ldquo;preview\u0026rdquo; problem: Direct the share URL to the Python backend, render a skeleton HTML page with machine-readable metadata, and send real browsers to the Angular app\u0026rsquo;s proper \u0026ldquo;read\u0026rdquo; page.\u003c/p\u003e\n\u003cp\u003eBut\u0026hellip; how exactly do you redirect the user?\u003c/p\u003e\n\u003cp\u003eMy first, naive idea was to use the age-old HTML redirect tag:\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003e\u0026lt;meta http-equiv=\u0026quot;refresh\u0026quot; content=\u0026quot;0; url=http://example.com/\u0026quot; \\\u0026gt;\u003c/code\u003e\u003c/p\u003e\n\u003cp\u003eMy assumption: social media bots wouldn\u0026rsquo;t follow these \u0026ldquo;semantic\u0026rdquo; redirects, but they would follow HTTP redirects (which they definitely do). This worked perfectly \u0026mdash; except for LinkedIn 😅\u003c/p\u003e","title":"Social media “previews” are tricky (part 2)"},{"content":"When you add a link to a website to post on Bluesky, Facebook, or LinkedIn, your post will often contain a \u0026ldquo;preview\u0026rdquo; of the target page. That\u0026rsquo;s great \u0026mdash; it makes the post more appealing and more likely to get clicks. But how do social media platforms fetch the exact content they embed in that preview? That\u0026rsquo;s\u0026hellip; a tiny rabbit hole.\nEnter: the \u0026ldquo;Share\u0026hellip;\u0026rdquo; feature of poketto.me. You can share any saved content with others via poketto.me. Anyone who opens the link gets the same distraction-free reading experience, whether or not they use poketto.me.\nNaturally, when you paste such a link on social media, you\u0026rsquo;d expect the post preview to capture the essence of the shared content: the title, a short description, and an image (if the source content has one).\nSocial media sites fetch the URL and parse metadata such as:\n\u0026lt;title\u0026gt;{title}\u0026lt;/title\u0026gt; \u0026lt;meta name=\u0026#34;description\u0026#34; property=\u0026#34;og:description\u0026#34; content=\u0026#34;{description}\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;image\u0026#34; property=\u0026#34;og:image\u0026#34; content=\u0026#34;{link_to_image}\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;author\u0026#34; content=\u0026#34;{author}\u0026#34; Here\u0026rsquo;s the catch: The poketto.me frontend is an Angular app. The HTML that social media sites \u0026ldquo;see\u0026rdquo; only loads the JavaScript app, which then loads the content. That means there\u0026rsquo;s no easy way to inject \u0026lt;meta\u0026gt; tags dynamically.\nHowever, my Python backend is a plain HTTP web server, so it can return any HTML it wants.\nMy solution:\n➡️ The \u0026ldquo;shareable\u0026rdquo; URL points to something like: https://app.poketto.me/s/\u0026lt;share_id\\\u0026gt;\n➡️ The Python backend handles this URL (without Angular) and returns a stub HTML page containing the relevant \u0026lt;meta\u0026gt; tags, plus a redirect to the \u0026ldquo;actual\u0026rdquo; read page: https://app.poketto.me/#/shared/\u0026lt;share_id\\\u0026gt;\nWhen a user opens the shared link in a browser, they\u0026rsquo;re immediately redirected to the read page. But when a social media bot fetches it, it sees only the machine-readable metadata.\nSo far, so good! But: Stay tuned for tomorrow, when I\u0026rsquo;ll share the one LinkedIn quirk that almost broke the entire setup 🤯\n","permalink":"https://build.ralphmayr.com/posts/62-social-media-previews-are-tricky-part-1/","summary":"\u003cp\u003eWhen you add a link to a website to post on Bluesky, Facebook, or LinkedIn, your post will often contain a \u0026ldquo;preview\u0026rdquo; of the target page. That\u0026rsquo;s great \u0026mdash; it makes the post more appealing and more likely to get clicks. But how do social media platforms fetch the exact content they embed in that preview? That\u0026rsquo;s\u0026hellip; a tiny rabbit hole.\u003c/p\u003e\n\u003cp\u003eEnter: the \u0026ldquo;Share\u0026hellip;\u0026rdquo; feature of poketto.me. You can share any saved content with others via poketto.me. Anyone who opens the link gets the same distraction-free reading experience, whether or not they use poketto.me.\u003c/p\u003e","title":"Social media “previews” are tricky (part 1)"},{"content":"I hadn\u0026rsquo;t actually planned to build a Firefox extension for poketto.me. But among the first wave of \u0026ldquo;Pocket Converts\u0026rdquo; \u0026mdash; users who turned to poketto.me after reading about it as an alternative to Pocket \u0026mdash; several asked for a Firefox extension. So, I decided to do a quick technical feasibility check.\nAs it turns out: The Chrome extension works in Firefox without a single code change!\nApparently, there are a few APIs that Firefox doesn\u0026rsquo;t support (see: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Browser_support_for_JavaScript_APIs), but luckily, I didn\u0026rsquo;t rely on any of those.\nThe only change I needed to make was adding a \u0026quot;browser_specific_settings\u0026quot; section to the manifest.json and assigning an ID. Uploading to the Mozilla Developer Hub ( https://addons.mozilla.org/en-US/developers/) is straightforward and completely free \u0026mdash; unlike Google, which charges a one-time $5 fee.\nNext up: Microsoft Edge? 🤨 The extension API is exactly the same as for Chrome and Firefox, so all I need to do is test and submit it 🚀\n","permalink":"https://build.ralphmayr.com/posts/61-firefox-and-chrome-finally-support-the-same-extension-api-and-so-does-microsoft-edge/","summary":"\u003cp\u003eI hadn\u0026rsquo;t actually planned to build a Firefox extension for poketto.me. But among the first wave of \u0026ldquo;Pocket Converts\u0026rdquo; \u0026mdash; users who turned to poketto.me after reading about it as an alternative to Pocket \u0026mdash; several asked for a Firefox extension. So, I decided to do a quick technical feasibility check.\u003c/p\u003e\n\u003cp\u003eAs it turns out: The Chrome extension works in Firefox without a single code change!\u003c/p\u003e\n\u003cp\u003eApparently, there are a few APIs that Firefox doesn\u0026rsquo;t support (see: \n\u003ca href=\"https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Browser_support_for_JavaScript_APIs%29\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehttps://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Browser_support_for_JavaScript_APIs)\u003c/a\u003e, but luckily, I didn\u0026rsquo;t rely on any of those.\u003c/p\u003e","title":"Firefox and Chrome (finally!) support the same extension API (and so does Microsoft Edge)"},{"content":"It\u0026rsquo;s generally good practice among developers to break things down into small, independent, reusable components. But, like all good things, it can become problematic when taken too far too soon.\nHere\u0026rsquo;s an example from poketto.me:\nA few weeks ago, I introduced colored tags. Users can choose one of eight colors for each tag to help them quickly find what they\u0026rsquo;re looking for and organize their tags visually. (Personally, I use one color for all location-related tags, another for tech topics, etc.)\nTo support this, I built a small color picker component \u0026mdash; Exhibit A.\nAt the time, I had a hunch that I might need it again somewhere else, so I didn\u0026rsquo;t bake it directly into the tags page. Instead, I tried to make it reusable for future use cases. The problem? I had no idea what those future use cases would actually be \u0026mdash; or how they\u0026rsquo;d work. So I ended up with a \u0026ldquo;reusable\u0026rdquo; color picker that was still tied to a single usage.\nFast forward a few weeks: I started working on the podcast feature, where users can create multiple podcast feeds. When sending saved content to one of these feeds, it\u0026rsquo;s easier to click the orange thing than to read the label \u0026ndash; Exhibit B. For example:\n🟧 My \u0026ldquo;Travel \u0026amp; Geography\u0026rdquo; feed is orange.\n🟦 My \u0026ldquo;Tech \u0026amp; Industry News\u0026rdquo; feed is blue.\n🟩 My \u0026ldquo;Nature \u0026amp; Sustainability\u0026rdquo; feed is green.\nThe color also determines how the feed appears in the podcast, and it\u0026rsquo;s nice to give them unique cover images for quick recognition \u0026ndash; Exhibit C.\nNaturally, I turned to my trusty color picker. But it turned out to be completely unsuitable.\nWhy?\nOn the tags page, the color picker simply selects one of several predefined colors (stored as hex strings). In the podcast settings, though, the \u0026ldquo;color\u0026rdquo; is actually a pointer to a cover image. Mapping hex strings to cover template filenames? Not exactly ideal.\nThe solution:\nI refactored the color picker, added a dedicated color service, and rewired code on both the tags page and the podcast settings page. I ended up reusing as much as possible \u0026mdash; but the extra work I originally put into making a \u0026ldquo;generic\u0026rdquo; color picker was wasted.\nLesson learned: I could have saved a couple of hours by keeping it embedded in the tags page and only generalizing later, once the second use case actually arrived.\n","permalink":"https://build.ralphmayr.com/posts/60-dont-generalize-too-soon-but-do-generalize/","summary":"\u003cp\u003eIt\u0026rsquo;s generally good practice among developers to break things down into small, independent, reusable components. But, like all good things, it can become problematic when taken too far too soon.\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s an example from poketto.me:\u003c/p\u003e\n\u003cp\u003eA few weeks ago, I introduced colored tags. Users can choose one of eight colors for each tag to help them quickly find what they\u0026rsquo;re looking for and organize their tags visually. (Personally, I use one color for all location-related tags, another for tech topics, etc.)\u003c/p\u003e","title":"Don’t generalize too soon. But do generalize."},{"content":"The other week, a post by someone at Google went viral. The gist: PMs should focus on building prototypes that show what the product is supposed to do, rather than merely telling through written PRDs.\nThat resonated with me.\nAs a developer-turned-PM, I always found it limiting that \u0026ldquo;building\u0026rdquo; was considered out of my scope. I got to write specs, user stories, and requirement docs \u0026mdash; but those often failed to capture the intent behind an idea, leading to botched execution of a feature. A lot got lost in translation.\nNow, working on poketto.me, I see the value of a multi-pronged approach:\n✅ I prototype directly in the actual codebase. As I wrote in TIL #26, I rarely create UI mockups \u0026mdash; I just build it in Angular straight away and show others what I\u0026rsquo;m up to.\n✅ I write about what I build \u0026mdash; like in yesterday\u0026rsquo;s TIL about toast notifications. Writing forces me to think: Why did I show a toast in this scenario, but not that one? Can I explain that decision clearly, or do I need another iteration? (While I wrote yesterday\u0026rsquo;s post, for example, I fixed a myriad of details about the toast notifications on the go!)\n✅ I demo features, live and in videos. And demoing is often where flaws get exposed. It\u0026rsquo;s when you walk through the entire user flow end-to-end \u0026mdash; and realize something hasn\u0026rsquo;t been fully thought through.\nWhether you're starting with specs or with scrappy prototypes, the risk is the same: Incompleteness.\nAnd the best way to catch that is:\nShow. And Tell. And Write.\n","permalink":"https://build.ralphmayr.com/posts/59-show-and-tell-and-write/","summary":"\u003cp\u003eThe other week, a post by someone at Google went viral. The gist: PMs should focus on building prototypes that show what the product is supposed to do, rather than merely telling through written PRDs.\u003c/p\u003e\n\u003cp\u003eThat resonated with me.\u003c/p\u003e\n\u003cp\u003eAs a developer-turned-PM, I always found it limiting that \u0026ldquo;building\u0026rdquo; was considered out of my scope. I got to write specs, user stories, and requirement docs \u0026mdash; but those often failed to capture the intent behind an idea, leading to botched execution of a feature. A lot got lost in translation.\u003c/p\u003e","title":"Show. And Tell. And Write."},{"content":"Some of the designers and frontend devs I\u0026rsquo;ve worked with may remember my rants against toast notifications:\n\u0026ldquo;Why do I need a success message for every action? I expect things to work. Only tell me when something breaks.\u0026rdquo;\nBut\u0026hellip; I\u0026rsquo;ll admit it: Sometimes toasts do have their merit.\nTwo examples from poketto.me:\n🔹 Copy to clipboard (Podcast feed URL):\nWhen users click the \u0026ldquo;copy\u0026rdquo; button, the action happens instantly. But without any feedback, it feels\u0026hellip; awkward.\nDid it work? Was the button even clickable?\nA quick toast (2\u0026ndash;3 seconds) saying \u0026ldquo;Copied to clipboard\u0026rdquo; removes the doubt and feels just right.\n🔹 Adding a Save to a podcast feed:\nThis is more complex. A Save might: Exceed the user\u0026rsquo;s monthly TTS quota, trigger auto-purging of older episodes, and take several minutes to process. Users need feedback now, but also more detailed info.\nMy current solution: Show a brief toast confirming the Save will be added (2s timeout) Then, follow it up with a second toast that contains detailed info (ETA, limits, etc.). That one doesn\u0026rsquo;t auto-dismiss, so users can read it at their own pace.\n","permalink":"https://build.ralphmayr.com/posts/58-the-occasional-toast-cant-hurt/","summary":"\u003cp\u003eSome of the designers and frontend devs I\u0026rsquo;ve worked with may remember my rants against toast notifications:\u003c/p\u003e\n\u003cp\u003e\u0026ldquo;Why do I need a success message for every action? I expect things to work. Only tell me when something breaks.\u0026rdquo;\u003c/p\u003e\n\u003cp\u003eBut\u0026hellip; I\u0026rsquo;ll admit it: Sometimes toasts do have their merit.\u003c/p\u003e\n\u003cp\u003eTwo examples from poketto.me:\u003c/p\u003e\n\u003cp\u003e🔹 Copy to clipboard (Podcast feed URL):\u003c/p\u003e\n\u003cp\u003eWhen users click the \u0026ldquo;copy\u0026rdquo; button, the action happens instantly. But without any feedback, it feels\u0026hellip; awkward.\u003c/p\u003e","title":"The occasional toast can’t hurt"},{"content":"Running text-to-speech in the cloud is fun\u0026mdash;until it isn\u0026rsquo;t.\nEarly on, I didn\u0026rsquo;t think much about thread safety. During my own testing, rarely would more than one TTS task be running in parallel, so there were no big issues. But once more users started using the feature, strange bugs popped up:\nErrors like \u0026ldquo;Assertion srcIndex \u0026lt; srcSelectDimSize failed\u0026rdquo; started showing up in the logs\u0026mdash;and worse, once triggered, the entire Cloud Run instance would become unusable until a redeploy.\nDigging in, I realized the culprit: When multiple TTS requests hit the same instance concurrently, all would fail. Why?\nBecause the parts of my TTS model that run on the CUDA GPU are not thread-safe. Only one can run at a time.\nHere\u0026rsquo;s my quick fix: I reconfigured Gunicorn to run with just one worker process and one thread, but with no timeout, so requests queue instead of running in parallel.\nCMD [\u0026#34;gunicorn\u0026#34;, \u0026#34;-w\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;--threads\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;--timeout\u0026#34;, \u0026#34;0\u0026#34;, \u0026#34;--keep-alive\u0026#34;, \u0026#34;30\u0026#34;, \u0026#34;--bind=0.0.0.0:8080\u0026#34;, \u0026#34;main:app\u0026#34;] Result: No more race conditions. Just good ol\u0026rsquo; first-come, first-served TTS.\n","permalink":"https://build.ralphmayr.com/posts/57-multi-threaded-tts-a-bad-idea/","summary":"\u003cp\u003eRunning text-to-speech in the cloud is fun\u0026mdash;until it isn\u0026rsquo;t.\u003c/p\u003e\n\u003cp\u003eEarly on, I didn\u0026rsquo;t think much about thread safety. During my own testing, rarely would more than one TTS task be running in parallel, so there were no big issues. But once more users started using the feature, strange bugs popped up:\u003c/p\u003e\n\u003cp\u003eErrors like \u0026ldquo;Assertion srcIndex \u0026lt; srcSelectDimSize failed\u0026rdquo; started showing up in the logs\u0026mdash;and worse, once triggered, the entire Cloud Run instance would become unusable until a redeploy.\u003c/p\u003e","title":"Multi-threaded TTS: A bad idea"},{"content":"One would think that poketto.me is so straightforward that any kind of architectural documentation would be unnecessary. But then...\nWhenever a user saves something, the app first fetches the raw content from the relevant webpage. Then, the content undergoes an asynchronous 'cleanup' process (as I mentioned previously, I use an LLM to fix broken formatting, etc.). If the user has turned on that setting, the content is then translated. This already results in five different states that a Save can be in: New (no content available yet), Extracted (raw content available), Processing (raw content cleaned up), Translating (content being translated), and 'None' (everything done). Of course, the frontend needs to be mindful of what to allow the user to do in each of these states.\nAnd then, the Podcast feature adds quite a bit more complexity. At any point, users can add the save to one of their podcast feeds \u0026ndash; and that makes sense, given that the user wouldn\u0026rsquo;t want to wait for poketto.me to have finished fetching, cleaning, translating the content before they can move it to their feed. Hence, what exactly this means depends on the state of the save: If it's 'New', 'Extracted', 'Processing' or 'Translating', it's not worth using TTS on the unfinished text. In this case, the save is added to a dedicated queue for later TTS processing. Therefore, once the translation (or processing) step is complete and TTS has been requested, the save enters the 'TTS'ing' state.\nThis is a bit more complicated than it looks at first glance.\nEnter: State diagrams! Read the above text again and then look at the attached state diagram. Which is easier to understand? Clearly, the diagram. It's definitely the asset I'll refer to if I need to debug something.\nP.S. It\u0026rsquo;s an interesting question whether this level of complexity merits upgrading the tech stack. Currently, it's hand-crafted, but: There are things out there like Google Cloud Dataflow, Dagster, Airflow, dbt and Confluent, each of which would have its merits over my homegrown approach.\n","permalink":"https://build.ralphmayr.com/posts/56-state-diagrams-are-fun/","summary":"\u003cp\u003eOne would think that poketto.me is so straightforward that any kind of architectural documentation would be unnecessary. But then...\u003c/p\u003e\n\u003cp\u003eWhenever a user saves something, the app first fetches the raw content from the relevant webpage. Then, the content undergoes an asynchronous 'cleanup' process (as I mentioned previously, I use an LLM to fix broken formatting, etc.). If the user has turned on that setting, the content is then translated. This already results in five different states that a Save can be in: New (no content available yet), Extracted (raw content available), Processing (raw content cleaned up), Translating (content being translated), and 'None' (everything done). Of course, the frontend needs to be mindful of what to allow the user to do in each of these states.\u003c/p\u003e","title":"State diagrams are fun!"},{"content":"In WebSockets: More than tech vanity, I talked about how poketto.me uses WebSockets to keep the UI in sync with async backend processes\u0026mdash;like translating, TTS, etc. Neat? Sure. Game-changing? Probably not. Just like I asked about AI in AI is not a value proposition: where would you put that on the Business Model Canvas? It doesn\u0026rsquo;t really fit as a Value Proposition, does it?\nBut that doesn\u0026rsquo;t mean it\u0026rsquo;s not important. Take Strava, for example.\nThe fitness app has over 150M users and a $2.2B valuation (as of early 2025). With a backlog full of feature requests, what did new CEO Michael Martin choose to prioritize in 2024?\n👉 Dark Mode.\nIt became an \u0026ldquo;all hands on deck\u0026rdquo; project and months of work for the whole company \u0026ndash; according to their CTO. Not because it created massive new value\u0026mdash;but because it met user expectations in 2025. Users wanted it (it topped their feedback portal), and it felt like something a \u0026ldquo;modern app\u0026rdquo; should have.\nThat\u0026rsquo;s the essence of the Kano Model: What starts as a delighter (Dark Mode, WebSockets) eventually becomes a baseline. You don\u0026rsquo;t win users with it, but you risk losing them if it\u0026rsquo;s missing.\nUX polish might not live in your value proposition box\u0026mdash;but it is part of the decision-making equation for every user.\nRead more:\n📎 Wall Street Journal: \u0026ldquo;Strava\u0026rsquo;s CEO Has a New Way to Improve Your Workouts\u0026rdquo;\n📎 Kano Model 101\n","permalink":"https://build.ralphmayr.com/posts/55-ux-details-sometimes-make-all-the-difference/","summary":"\u003cp\u003eIn \n\u003ca href=\"../51-websockets-more-than-tech-vanity/\"\u003eWebSockets: More than tech vanity\u003c/a\u003e, I talked about how poketto.me uses WebSockets to keep the UI in sync with async backend processes\u0026mdash;like translating, TTS, etc. Neat? Sure. Game-changing? Probably not. Just like I asked about AI in \n\u003ca href=\"../54-ai-is-not-a-value-proposition/\"\u003eAI is not a value proposition\u003c/a\u003e: where would you put that on the Business Model Canvas? It doesn\u0026rsquo;t really fit as a Value Proposition, does it?\u003c/p\u003e\n\u003cp\u003eBut that doesn\u0026rsquo;t mean it\u0026rsquo;s not important. Take Strava, for example.\u003c/p\u003e","title":"UX details sometimes make all the difference"},{"content":"I didn\u0026rsquo;t coin this phrase (sadly), but it keeps proving itself true\u0026mdash;especially now that I\u0026rsquo;m working on GTM details for some of the more advanced features in poketto.me.\nMost users don\u0026rsquo;t care how your app works. They care what it does for them\u0026mdash;and whether that\u0026rsquo;s worth paying for.\nSince LLMs became easy to embed, companies started slapping \u0026ldquo;powered by AI\u0026rdquo; stickers on everything as if that alone justified a price tag. But unless the user clearly feels the value, it doesn\u0026rsquo;t matter what's under the hood. Case in point: Garmin\u0026rsquo;s hilariously underwhelming $7/month \u0026ldquo;AI subscription\u0026rdquo;. The so-called \u0026ldquo;insights\u0026rdquo; offered nothing users couldn\u0026rsquo;t deduce themselves\u0026mdash;or the app couldn\u0026rsquo;t have generated with much simpler logic.\nLook at your business model canvas: AI belongs in the Resources box, maybe the Cost Structure. Not in Value Propositions.\nFor poketto.me, AI is an enabler\u0026mdash;not the pitch. It lets me build things that weren\u0026rsquo;t possible just a few years ago. But what matters to users is the value they receive:\n🟢 Seamless translations\n→ Pain: Content isn\u0026rsquo;t in my language; copying and pasting stuff to and from translation apps is a hassle\n→ Gain: Automatic, high-quality translations that just work\n🟢 Smooth PDF reading on small screens\n→ Pain: Zooming, pinching, and endless scrolling\n→ Gain: PDFs reformatted for effortless mobile reading\n🟢 Turning web content into great audio\n→ Pain: Reading long texts on glossy screens is tiring, most TTS tools are clunky, and the TTS features of most websites only works while online\n→ Gain: Clean, rephrased, high-quality audio, available offline via any podcast app\nAI helps enable these gains. But it\u0026rsquo;s not what I\u0026rsquo;m selling.\nPS: I once wrote a blog post on the value proposition canvas and its friends\u0026mdash;still holds up.\n","permalink":"https://build.ralphmayr.com/posts/54-ai-is-not-a-value-proposition/","summary":"\u003cp\u003eI didn\u0026rsquo;t coin this phrase (sadly), but it keeps proving itself true\u0026mdash;especially now that I\u0026rsquo;m working on GTM details for some of the more advanced features in poketto.me.\u003c/p\u003e\n\u003cp\u003eMost users don\u0026rsquo;t care how your app works. They care what it does for them\u0026mdash;and whether that\u0026rsquo;s worth paying for.\u003c/p\u003e\n\u003cp\u003eSince LLMs became easy to embed, companies started slapping \u0026ldquo;powered by AI\u0026rdquo; stickers on everything as if that alone justified a price tag. But unless the user clearly feels the value, it doesn\u0026rsquo;t matter what's under the hood. Case in point: Garmin\u0026rsquo;s hilariously underwhelming $7/month \u0026ldquo;AI subscription\u0026rdquo;. The so-called \u0026ldquo;insights\u0026rdquo; offered \n\u003ca href=\"https://www.techradar.com/health-fitness/smartwatches/garmins-new-subscription-ai-feature-is-hilariously-bad-so-far\" target=\"_blank\" rel=\"noopener noreferrer\"\u003enothing users couldn\u0026rsquo;t deduce themselves\u003c/a\u003e\u0026mdash;or the app couldn\u0026rsquo;t have generated with much simpler logic.\u003c/p\u003e","title":"AI is not a value proposition"},{"content":"You\u0026rsquo;ve heard it before: It\u0026rsquo;s a marathon, not a sprint. That\u0026rsquo;s true for almost everything worthwhile\u0026mdash;learning a new skill, building healthy habits, nurturing relationships\u0026hellip; and yes, building, maintaining, and growing something like poketto.me, even if it's \u0026ldquo;just\u0026rdquo; a side project.\nPsychologist Angela Duckworth frames this through the lens of grit\u0026mdash;not sheer willpower (which is fleeting and highly dependent on external factors), but the combination of passion (what gets you started) and perseverance (what keeps you going).\nFor me, poketto.me began with passion\u0026mdash;the \u0026ldquo;joy of building.\u0026rdquo; It felt like play. Quick feedback loops, visible progress, flow states, and the simple fun of making something from scratch. That passion got me to start\u0026mdash;but couldn\u0026rsquo;t carry me all the way. Eventually, building features turns into refining, debugging, documenting\u0026mdash;aka, \u0026ldquo;real\u0026rdquo; work.\nThat\u0026rsquo;s where perseverance kicks in. And here, my experience with habit-building helped a lot. Over the last ten years, I established healthy routines that integrate so seamlessly with the rest of my day that they feel \u0026ldquo;effortless\u0026rdquo; (to borrow the term recently coined by Greg McKeown).\n🏃‍♂️ I run every morning, before even looking at my phone.\n📚 I read at least one hour a day.\n🧘 I meditate 15 minutes in the morning and at night.\nNone of these require motivation or willpower\u0026mdash;they\u0026rsquo;re just part of the day. Likewise, I made it a rule to work on poketto.me at least 30 minutes a day. Some days I only tackle a low-hanging fruit (see TIL #51), but most days, that small momentum leads to more.\nYou don\u0026rsquo;t always need fire. Sometimes, routine is the fuel.\nAs they say: It\u0026rsquo;s a marathon, not a sprint.\n","permalink":"https://build.ralphmayr.com/posts/53-passion-is-what-gets-you-started/","summary":"\u003cp\u003eYou\u0026rsquo;ve heard it before: It\u0026rsquo;s a marathon, not a sprint. That\u0026rsquo;s true for almost everything worthwhile\u0026mdash;learning a new skill, building healthy habits, nurturing relationships\u0026hellip; and yes, building, maintaining, and growing something like poketto.me, even if it's \u0026ldquo;just\u0026rdquo; a side project.\u003c/p\u003e\n\u003cp\u003ePsychologist Angela Duckworth frames this through the lens of \u003cstrong\u003egrit\u003c/strong\u003e\u0026mdash;not sheer \u003cstrong\u003ewillpower\u003c/strong\u003e (which is fleeting and highly dependent on external factors), but the combination of \u003cstrong\u003epassion\u003c/strong\u003e (what gets you started) and \u003cstrong\u003eperseverance\u003c/strong\u003e (what keeps you going).\u003c/p\u003e","title":"Passion is what gets you started…"},{"content":"In WebSockets: More than tech vanity, I talked about the implicit user value of asynchronous UIs. Today, here\u0026rsquo;s a neat little JavaScript trick I hadn\u0026rsquo;t been aware of that makes implementing them a bit easier: Object.assign(...).\nHere\u0026rsquo;s the situation:\nYour Angular frontend initially fetches a list of items from the backend. Thanks to Angular\u0026rsquo;s powerful data binding, many components reference properties of these objects. Later, the backend sends an updated version of one of these items via WebSocket. What now?\nSure, you can notify interested components via an RxJS subscription. But should every component compare the new object with the old one and manually update fields?\nA more elegant approach: Notify the one component that for sure holds a reference to the object\u0026mdash;in my case, the SaveListComponent. Then simply call:\nObject.assign(oldObject, newObject); This keeps the original object reference intact (so no list reordering or re-rendering quirks), while updating its properties in place. Thanks to Angular\u0026rsquo;s change detection, any component bound to that object sees the updated data automatically.\n","permalink":"https://build.ralphmayr.com/posts/52-object-assign-in-javascript-helps-with-asynchronous-updates/","summary":"\u003cp\u003eIn \n\u003ca href=\"../51-websockets-more-than-tech-vanity/\"\u003eWebSockets: More than tech vanity\u003c/a\u003e, I talked about the implicit user value of asynchronous UIs. Today, here\u0026rsquo;s a neat little JavaScript trick I hadn\u0026rsquo;t been aware of that makes implementing them a bit easier: \u003ccode\u003eObject.assign(...)\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s the situation:\u003c/p\u003e\n\u003cp\u003eYour Angular frontend initially fetches a list of items from the backend. Thanks to Angular\u0026rsquo;s powerful data binding, many components reference properties of these objects. Later, the backend sends an updated version of one of these items via WebSocket. What now?\u003c/p\u003e","title":"Object.assign(...) in JavaScript helps with asynchronous updates"},{"content":"As I said in Socket science isn\u0026rsquo;t rocket science, WebSockets are quite easy to use with Python + Flask + Socket.IO on the backend and Angular + Socket.IO + RxJS on the frontend. But why bother? Dealing with asynchronous requests is more of a hassle than not. And for a \u0026ldquo;boring\u0026rdquo; app like poketto.me, aren\u0026rsquo;t synchronous HTTP requests/responses good enough?\nHere are two use cases where, despite the app\u0026rsquo;s apparent simplicity, asynchronous backend/frontend communication adds real value:\n➡️ Notifications: Many operations poketto.me performs actually run in the background\u0026mdash;smoothing content, translating it, optimizing it for listening, etc. I can\u0026rsquo;t have an HTTP request stall for several minutes while these things happen (and show an endless progress spinner to the user.) And regular polling for status updates isn\u0026rsquo;t ideal either. With WebSockets, I can simply notify the client when tasks are complete. Notice, for example, how the status indicator in the top right corner of a Save changes and disappears over time, all on its own.\n➡️ Consistent state across browser tabs: Most users won\u0026rsquo;t have multiple poketto.me tabs open\u0026mdash;but some will. And many will have the app open in one tab while using the Chrome Extension in another to save a web page. When they return to the app, they expect the saved page to already be there. Same on mobile.\nOf course, these features aren\u0026rsquo;t directly \u0026ldquo;monetizable.\u0026rdquo; No one pays just for seamless tab syncing. But users\u0026rsquo; expectations of good UX keep evolving\u0026mdash;and how the app feels plays a huge role in whether people keep using it, or even upgrade to a paid version.\n","permalink":"https://build.ralphmayr.com/posts/51-websockets-more-than-tech-vanity/","summary":"\u003cp\u003eAs I said in \n\u003ca href=\"../18-socket-science-isnt-rocket-science-or-websockets-flask-socketio/\"\u003eSocket science isn\u0026rsquo;t rocket science\u003c/a\u003e, WebSockets are quite easy to use with Python + Flask + Socket.IO on the backend and Angular + Socket.IO + RxJS on the frontend. But why bother? Dealing with asynchronous requests is more of a hassle than not. And for a \u0026ldquo;boring\u0026rdquo; app like poketto.me, aren\u0026rsquo;t synchronous HTTP requests/responses good enough?\u003c/p\u003e\n\u003cp\u003eHere are two use cases where, despite the app\u0026rsquo;s apparent simplicity, asynchronous backend/frontend communication adds real value:\u003c/p\u003e","title":"WebSockets: More than tech vanity"},{"content":"Under the hood, poketto.me makes heavy use of LLMs. The podcast feature is a great example: Users can turn any web content into a podcast, but often that content isn\u0026rsquo;t well-suited for listening. LLMs are great at optimizing this\u0026mdash;simplifying complex sentences, turning headlines into enumerations, describing images verbally, etc.\nBut the challenge: How do you craft a single, generic prompt that works across all types of content and runs unsupervised via the API?\nMy original prompt had a long list of dos and don\u0026rsquo;ts\u0026mdash;like \u0026ldquo;don\u0026rsquo;t repeat the task description,\u0026rdquo; or \u0026ldquo;don\u0026rsquo;t explain what you did.\u0026rdquo; Still, occasionally, the model would insert lines like \u0026ldquo;Here\u0026rsquo;s the optimized version\u0026rdquo; or \u0026ldquo;This version simplifies\u0026hellip;\u0026rdquo; (See Exhibit A. Super annoying.)\nThe fix? Ask the LLMs.\nI handed the task of rewriting the prompt to the LLMs themselves\u0026mdash;ChatGPT, Grok, DeepSeek. I described the goal and asked them how the prompt should be structured. Turns out, my original prompt was fine; it just wasn\u0026rsquo;t formatted right.\nAfter switching to the improved structure (Exhibit B), those issues disappeared. And\u0026mdash;bonus\u0026mdash;the podcast scripts got noticeably better.\n","permalink":"https://build.ralphmayr.com/posts/50-prompt-engineering-a-task-best-left-to-the-machines/","summary":"\u003cp\u003eUnder the hood, poketto.me makes heavy use of LLMs. The podcast feature is a great example: Users can turn any web content into a podcast, but often that content isn\u0026rsquo;t well-suited for listening. LLMs are great at optimizing this\u0026mdash;simplifying complex sentences, turning headlines into enumerations, describing images verbally, etc.\u003c/p\u003e\n\u003cp\u003eBut the challenge: How do you craft a single, generic prompt that works across all types of content and runs unsupervised via the API?\u003c/p\u003e","title":"Prompt engineering: A task best left to the machines"},{"content":"As I pointed out a few weeks ago: The web, as it's designed today, is not ready for \u0026ldquo;Agents\u0026rdquo; of any kind\u0026mdash;AI-driven or just plain old automation scripts. Why? Because there\u0026rsquo;s no agreed-upon way for machines to interact with websites on behalf of a user.\nCase in point: Paywalls.\nPublishers are getting more creative in protecting their content from scraping, and rightly so: no one wants their work stolen by AI companies or repackaged by Google. But at the same time, they want to provide a good user experience for those who pay.\nSome of the quirkiest hacks I\u0026rsquo;ve seen:\n🤯 DerStandard.at: Uses JavaScript to render content only in full browsers; the content isn\u0026rsquo;t in the raw HTML.\n🤯 NYTimes.com: Possibly the strictest\u0026mdash;blocks most scrapers and often even browsers unless you solve a CAPTCHA.\n🤯 TheGuardian.com: Easy to scrape the article, but not the headline image\u0026mdash;image URLs require hash-based authorization.\npoketto.me is built on the idea that you, the user, should have the choice when, where, and in which format you want to consume web content. So how am I handling the scraping situation?\n➡️Currently: I\u0026rsquo;m only scraping what\u0026rsquo;s \u0026ldquo;easily\u0026rdquo; accessible. For most websites, that\u0026rsquo;s \u0026ldquo;good enough.\u0026rdquo; Sometimes that even allows one to peek behind a poorly implemented paywall.\n➡️Possibly next: A Selenium-based solution for complex cases. Simulating a \u0026ldquo;real\u0026rdquo; browser would get poketto.me access also in tricker cases \u0026ndash; like DerStandard.at\n➡️Maybe later: Enhancing the Chrome extension to fetch content from your local browser session (e.g., when you\u0026rsquo;re already logged into NYT).\nBut the real fix? What we need is a protocol like this:\n➡️ A paying user has a subscription\n➡️ They grant poketto.me access via credentials\n➡️ poketto.me fetches content on their behalf, with proper authentication\nThus, the user gets what they paid for (the content) and the choice when and where to consume it. The publisher gets paid. And poketto.me facilitates the transaction. But: That\u0026rsquo;s a long way off, still. Cloudflare has recently introduced a \u0026ldquo;pay per crawl\u0026rdquo; option\u0026mdash;let\u0026rsquo;s see if that develops in the right direction!\n","permalink":"https://build.ralphmayr.com/posts/49-how-to-not-do-paywalls/","summary":"\u003cp\u003eAs I pointed out a few weeks ago: The web, as it's designed today, is not ready for \u0026ldquo;Agents\u0026rdquo; of any kind\u0026mdash;AI-driven or just plain old automation scripts. Why? Because there\u0026rsquo;s no agreed-upon way for machines to interact with websites on behalf of a user.\u003c/p\u003e\n\u003cp\u003eCase in point: Paywalls.\u003c/p\u003e\n\u003cp\u003ePublishers are getting more creative in protecting their content from scraping, and rightly so: no one wants their work stolen by AI companies or repackaged by Google. But at the same time, they want to provide a good user experience for those who pay.\u003c/p\u003e","title":"How to (not) do paywalls"},{"content":"I don\u0026rsquo;t remember where I first came across this metaphor, but I still love it:\nSuppose your task is to train a costumed monkey to recite a Shakespeare sonnet while standing on an elaborately carved wooden pedestal. Where do you start? Do you begin by picking out the wood for the pedestal? Designing the decorative motifs? Choosing which hat the monkey should wear? Or\u0026mdash;more obviously\u0026mdash;do you first figure out whether you can actually get a monkey to recite poetry in the first place?\nIn this parable, the key to success lies in solving the hardest, most uncertain part of the challenge first: training the monkey. And yet, in product development, we often fall into the exact opposite pattern. We busy ourselves with the easy, low-risk, but time-consuming tasks\u0026mdash;while putting off the hard questions that ultimately determine success.\nIn tech, this might look like teams diving into infrastructure early on: setting up build pipelines, cloud deployments, internal tooling, or debating frameworks. While important eventually, none of that matters if your core hypothesis turns out to be wrong. The real questions early on often are:\n🧐Can the AI model actually do what we need it to?\n🧐Can we create a UX that feels intuitive and delightful?\n🧐How much will it cost to run this thing?\n🧐Do users even want what we\u0026rsquo;re building?\nProduct managers (mea culpa 🙋‍♂️) fall into this trap too. We\u0026rsquo;ll spend days on intricate pricing models, GTM plans, competitive analyses or customer service workflows for an app that doesn\u0026rsquo;t yet have a single user. That might feel like progress\u0026mdash;but if we haven\u0026rsquo;t validated desirability, feasibility, or viability, it\u0026rsquo;s just premature optimization.\nThe podcast functionality in poketto.me is a good illustration of this mindset. There was a lot of backend plumbing I knew I\u0026rsquo;d eventually need:\n➡️Cleaning and adapting raw article text for speech\n➡️Generating and serving MP3s\n➡️Creating and updating podcast feed XMLs\n➡️Deploying a Cloud Run instance for the TTS model\nAll of that is complex\u0026mdash;but it\u0026rsquo;s predictably complex. I was confident I could build it when the time came. The real question\u0026mdash;the metaphorical monkey\u0026mdash;was this:\n🧐Can an open-source TTS model generate speech that\u0026rsquo;s good enough for casual listening? And: Can I run such a model in the cloud cost-effectively, in a way that a sustainable pricing model could support?\nThat\u0026rsquo;s what I tackled first. I ran quick tests, iterated on voice quality, evaluated infrastructure cost, and only once I had confidence in the answers did I \u0026ldquo;allow\u0026rdquo; myself to start building out the full system around it.\nNo, this doesn't guarantee success. But by tackling the monkey first, I avoided the risk of pouring weeks into a polished, production-grade feature that might have never cleared its most critical technical and economic hurdles.\nIt\u0026rsquo;s a lesson worth repeating\u0026mdash;for solo builders, startup teams, and corporate product squads alike:\nDon\u0026rsquo;t start with the pedestal. Start with the monkey\n","permalink":"https://build.ralphmayr.com/posts/48-tackle-the-monkey-first/","summary":"\u003cp\u003eI don\u0026rsquo;t remember where I first came across this metaphor, but I still love it:\u003c/p\u003e\n\u003cp\u003eSuppose your task is to train a costumed monkey to recite a Shakespeare sonnet while standing on an elaborately carved wooden pedestal. Where do you start? Do you begin by picking out the wood for the pedestal? Designing the decorative motifs? Choosing which hat the monkey should wear? Or\u0026mdash;more obviously\u0026mdash;do you first figure out whether you can actually get a monkey to recite poetry in the first place?\u003c/p\u003e","title":"Tackle the Monkey First"},{"content":"A favicon is that tiny icon you see next to a site name in your browser tab or bookmarks bar. It's one of those small UX elements that quietly plays a big role in how we recognize and visually differentiate websites.\nIn poketto.me, I wanted to bring favicons into play for a couple of UI elements\u0026mdash;particularly when managing your saved news sources. Seeing a little logo beside each source makes skimming, scanning, and organizing much more intuitive than reading domain names alone.\nBut here\u0026rsquo;s the thing: there\u0026rsquo;s no clean, standardized way to get a website\u0026rsquo;s favicon. And that surprised me more than it should have.\nSo\u0026hellip; how do you fetch the favicon for a given webpage?\nThere\u0026rsquo;s no universal API, and there\u0026rsquo;s no HTML spec that guarantees every site will expose one in the same way. But, with a few heuristics, you can cover a decent chunk of cases:\nMany modern pages include one or more \u0026lt;link\u0026gt; tags referencing the favicon. These might look like:\n\u0026lt;link rel=\u0026quot;icon\u0026quot; href=\u0026quot;/favicon.ico\u0026quot; type=\u0026quot;image/x-icon\u0026quot;\u0026gt;\n\u0026lt;link rel=\u0026quot;icon\u0026quot; href=\u0026quot;/favicon.png\u0026quot; type=\u0026quot;image/png\u0026quot;\u0026gt;\n\u0026lt;link rel=\u0026quot;shortcut icon\u0026quot; href=\u0026quot;/favicon.ico\u0026quot;\u0026gt;\n\u0026lt;link rel=\u0026quot;apple-touch-icon\u0026quot; href=\u0026quot;/apple-touch-icon.png\u0026quot;\u0026gt;\nThese links may point to:\nRelative URLs (e.g., /apple-touch-icon.png)\nFull URLs (e.g., https://example.com/favicon.ico)\nFallback to the root location:\nIf all else fails, you can usually try fetching the default:\nhttps://example.com/favicon.ico\nThis still works for many legacy and static sites. What makes this tricky?\nNo guarantees: Not all sites include the \u0026lt;link rel=\u0026quot;icon\u0026quot;\u0026gt; tag. Some modern web apps skip it entirely or load it dynamically.\nMultiple icons: Sites often provide several icons for different platforms (e.g., iOS touch icons, pinned Safari tabs, etc.).\nDifferent file formats: Favicons can be .ico, .png, .svg, or even served through unusual formats or scripts.\nRedirects and permissions: Some icons require cookies or auth headers to fetch. Others redirect through CDNs.\nSize and quality: Some icons are tiny and pixelated; others are high-res logos. Picking the \u0026quot;right\u0026quot; one automatically is non-trivial.\nSo, what\u0026rsquo;s my solution in poketto.me?\nRight now, I\u0026rsquo;ve implemented a best-effort approach:\nParse the original page\u0026rsquo;s HTML to look for known \u0026lt;link\u0026gt; patterns.\nIf none found, try /favicon.ico at the domain root.\nPrefer png over ico when both are available.\nIt's not bulletproof\u0026mdash;but it works well enough for most mainstream websites.\n","permalink":"https://build.ralphmayr.com/posts/47-extracting-favicons-theres-no-bulletproof-way/","summary":"\u003cp\u003eA \u003cstrong\u003efavicon\u003c/strong\u003e is that tiny icon you see next to a site name in your browser tab or bookmarks bar. It's one of those small UX elements that quietly plays a big role in how we recognize and visually differentiate websites.\u003c/p\u003e\n\u003cp\u003eIn poketto.me, I wanted to bring favicons into play for a couple of UI elements\u0026mdash;particularly when managing your \u003cstrong\u003esaved news sources\u003c/strong\u003e. Seeing a little logo beside each source makes skimming, scanning, and organizing much more intuitive than reading domain names alone.\u003c/p\u003e","title":"Extracting Favicons: There’s No Bulletproof Way"},{"content":"I love GCS (Google Cloud Storage). It\u0026rsquo;s a simple, robust, and powerful solution for storing files online and accessing them either programmatically or via HTTP. Storage is dirt cheap\u0026mdash;especially if you don\u0026rsquo;t need global replication or sophisticated backups. And you can even turn a GCS bucket into an HTTPS-secured, internet-facing web server for static websites. https://poketto.me, for example, runs on that architecture.\nAnother good use case: the #podcast feature in poketto.me. Naturally, the generated MP3 files need to live somewhere, and storing them in a database or serving them through my Python web server would be\u0026hellip; silly. So I push the generated files to a GCS bucket, and all is well: HTTPS-secured, fast, and compatible with any podcast client in the world.\nBut here's the catch: I also stored the podcast feed XML (i.e. the \u0026quot;inventory\u0026quot; that tells podcast tools which episodes are available, where the MP3s live, how long each plays, and other metadata) in GCS. Every time you add a new episode, the app overwrites that XML file. Your podcast tool should pick it up.\nShould.\nEnter: GCS caching.\nGCS uses aggressive caching mechanisms\u0026mdash;great for performance, terrible for dynamic content. Often, when accessing the updated podcast feed, you\u0026rsquo;ll just get a cached, outdated version. That means your shiny new episode doesn\u0026rsquo;t show up for hours. Frustrating, especially when you just saved an article and expect it to appear in your podcast tool right away.\nTechnically, one can work around this. A common trick is to append a cachebuster query string like ?version=42 to the feed URL, which forces GCS to serve the latest content. But realistically, you wouldn\u0026rsquo;t want users to delete and re-add the podcast feed every time they add a new episode, right?\nMy solution (for now) is twofold:\n1️⃣Server-side: I apply as many \u0026quot;no cache\u0026quot; settings as possible when pushing the XML file to the bucket. Doesn\u0026rsquo;t help that much, but improves the situation a bit.\n2️⃣Medium-term: I\u0026rsquo;ll stop serving the podcast feed directly from GCS. Instead, I\u0026rsquo;ll route it through the Python backend. That way, an HTTP endpoint under my control can fetch the latest file from GCS and enforce cache-busting behind the scenes\u0026mdash;while keeping the actual feed URL stable for users.\nGCS is still a fantastic tool\u0026mdash;but just because you can serve dynamic content from it doesn\u0026rsquo;t always mean you should.\n","permalink":"https://build.ralphmayr.com/posts/46-gcs-caching-can-be-a-pain-in-the-neck/","summary":"\u003cp\u003eI love GCS (Google Cloud Storage). It\u0026rsquo;s a simple, robust, and powerful solution for storing files online and accessing them either programmatically or via HTTP. Storage is dirt cheap\u0026mdash;especially if you don\u0026rsquo;t need global replication or sophisticated backups. And you can even turn a GCS bucket into an HTTPS-secured, internet-facing web server for static websites. \n\u003ca href=\"https://poketto.me\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehttps://poketto.me\u003c/a\u003e, for example, runs on that architecture.\u003c/p\u003e\n\u003cp\u003eAnother good use case: the #podcast feature in poketto.me. Naturally, the generated MP3 files need to live somewhere, and storing them in a database or serving them through my Python web server would be\u0026hellip; silly. So I push the generated files to a GCS bucket, and all is well: HTTPS-secured, fast, and compatible with any podcast client in the world.\u003c/p\u003e","title":"GCS Caching Can Be a Pain in the Neck"},{"content":"Just like optimism vs. pessimism, there's another spectrum that every builder, founder, or product person lives on: Maximizing vs. Satisficing.\nIn behavioral economics, a maximizer tries to achieve the best possible outcome. For example: spending hours to find the absolute best hotel for your vacation. A satisficer, on the other hand, picks the first option that meets their basic requirements\u0026mdash;and moves on with their day.\nWhen developing products, it's incredibly useful to know where you fall on that scale because: There\u0026rsquo;s not simple answer when to apply which strategy.\nPersonally, I know I tend to lean toward maximizing. I want things to be just right before putting them in front of users. There are upsides: People learn they can trust what you ship. That you\u0026rsquo;re a person who has their i\u0026rsquo;s dotted and their t\u0026rsquo;s crossed. But the downsides? You can easily get stuck in your head, polishing endlessly. And after you\u0026rsquo;ve added and removed the box-shadow of you primary button for the tenth time, only then learned that your basic assumptions were off, that time was largely wasted.\nGo too far in the other direction, though\u0026mdash;launching rough prototypes too early\u0026mdash;and the feedback you get is often low value:\n\u0026ldquo;It crashed.\u0026rdquo;\n\u0026ldquo;The UI is confusing.\u0026rdquo;\n\u0026ldquo;Where\u0026rsquo;s feature X?\u0026rdquo;\nThat kind of feedback isn\u0026rsquo;t wrong, but it doesn\u0026rsquo;t tell you much about whether the core idea is valid.\nThere\u0026rsquo;s no universal rule for when to maximize and when to satisfice. But here\u0026rsquo;s how I approached this at poketto.me:\nWhen I first launched the MVP, I aimed for feature completeness, not polish. I wanted the save → tag → read → archive flow to work smoothly\u0026mdash;but I didn\u0026rsquo;t obsess over tiny UX details. That was satisficing, and it worked: the core feedback was about what people could do, not what they couldn\u0026rsquo;t.\nFast-forward to building personal podcasts \u0026mdash; a much more complex and (hopefully) monetizable feature. I had a working prototype weeks ago. But it only had:\nOne voice\nNo intros/outros\nNo usage limits\nNo transcript view\nNo customization options\nI could\u0026rsquo;ve launched it early, but I didn\u0026rsquo;t. This was a moment to maximize. And yes, it took way longer than Optimistic Me thought it would (surprise, surprise 😅). But now?\nUsers can pick from six voices\nChoose between raw text or an AI-enhanced narration\nSelect original or auto-translated content\nView full transcripts\nAnd I can set reasonable usage limits behind the scenes\nNow, I feel confident putting this in front of people and asking: What do you think? Because the fundamentals are solid\u0026mdash;and the feedback will (hopefully) go deeper than \u0026quot;the basics don\u0026rsquo;t work.\u0026quot;\nOf course, I still have a million ideas. For instance: Wouldn\u0026rsquo;t it be nice to share directly from Chrome to your podcast feed\u0026mdash;no jumping into the app first?\nYes. But that\u0026rsquo;s probably gold plating for now. My gut says: not yet.\nKnowing when to polish and when to ship is one of the hardest judgment calls in product development. But learning to switch modes\u0026mdash;maximizing where it matters and satisficing where it doesn\u0026rsquo;t\u0026mdash;is probably one of the most important skills you can develop.\n","permalink":"https://build.ralphmayr.com/posts/45-know-when-to-maximizeand-when-to-satisfice/","summary":"\u003cp\u003eJust like optimism vs. pessimism, there's another spectrum that every builder, founder, or product person lives on: Maximizing vs. Satisficing.\u003c/p\u003e\n\u003cp\u003eIn behavioral economics, a maximizer tries to achieve the \u003cem\u003ebest possible outcome\u003c/em\u003e. For example: spending hours to find the absolute best hotel for your vacation. A satisficer, on the other hand, picks the \u003cem\u003efirst\u003c/em\u003e option that meets their basic requirements\u0026mdash;and moves on with their day.\u003c/p\u003e\n\u003cp\u003eWhen developing products, it's incredibly useful to know where you fall on that scale because: There\u0026rsquo;s not simple answer when to apply which strategy.\u003c/p\u003e","title":"Know When to Maximize—and When to Satisfice"},{"content":"There was a time when the golden rule of consumer app development was as simple as ABCD: Always Be Collecting Data.\nThe strategy?\n1️⃣ Grow your user base as fast as possible.\n2️⃣ Track every interaction, every event, every click.\n3️⃣ Figure out how to monetize the data \u0026mdash; usually through targeted advertising, if you couldn\u0026rsquo;t think of anything more creative.\nBut that game is changing. Consumers are more privacy-aware than ever. Regulators \u0026mdash; especially in the EU, California, Japan, and a few other regions \u0026mdash; have stepped in. And both founders and investors are realizing that data-harvesting at scale is not a sustainable or ethical business model.\nWith poketto.me, I\u0026rsquo;m taking a different path. Rather than play the \u0026quot;data = gold\u0026quot; game, I\u0026rsquo;m trying to adhere as closely as possible to the GDPR\u0026rsquo;s principle of data minimization\u0026mdash;collecting only what\u0026rsquo;s needed, with a clear purpose in mind.\nHere are just a few things I could track \u0026mdash; but currently don\u0026rsquo;t:\n📍 Your location\n🎂 Your age or gender\n⏳ Time spent in the app\n📖 How long you spend reading each article\n📚 The types of content you\u0026rsquo;re saving (news, essays, papers, etc.)\nIf monetization ever happens, it\u0026rsquo;ll have to be a direct consequence of real user value. Features like personal podcasts, curated news feeds, better organization and search \u0026mdash; that\u0026rsquo;s what I\u0026rsquo;d ask people to pay for. Not their attention. Not their data.\nBecause ultimately, I believe that\u0026rsquo;s the more honest way. Your customer shouldn\u0026rsquo;t be your product.\nWe just need to come up with something else for ABCD to stand for.\nAny ideas? 🤔\n","permalink":"https://build.ralphmayr.com/posts/44-the-abcd-rule-doesnt-cut-it-anymore/","summary":"\u003cp\u003eThere was a time when the golden rule of consumer app development was as simple as \u003cstrong\u003eABCD\u003c/strong\u003e: \u003cstrong\u003eAlways Be Collecting Data.\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThe strategy?\u003cbr\u003e\n1️⃣ Grow your user base as fast as possible.\u003cbr\u003e\n2️⃣ Track every interaction, every event, every click.\u003cbr\u003e\n3️⃣ Figure out how to \u003cem\u003emonetize the data\u003c/em\u003e \u0026mdash; usually through targeted advertising, if you couldn\u0026rsquo;t think of anything more creative.\u003c/p\u003e\n\u003cp\u003eBut that game is changing. Consumers are more privacy-aware than ever. Regulators \u0026mdash; especially in the EU, California, Japan, and a few other regions \u0026mdash; have stepped in. And both founders and investors are realizing that \u003cem\u003edata-harvesting at scale\u003c/em\u003e is not a sustainable or ethical business model.\u003c/p\u003e","title":"The ABCD rule doesn’t cut it anymore"},{"content":"This one\u0026rsquo;s a pretty common hassle: You\u0026rsquo;ve got text with line breaks (encoded as \\n or sometimes \\r\\n \u0026mdash; thanks, Microsoft!), but when rendering that text on a webpage, those line breaks are nowhere to be seen. Naturally, browsers collapse \u0026lsquo;source\u0026rsquo; line breaks and ignore them.\nThe usual reaction? Reach for an nl2br utility \u0026mdash; like nl2br-pipe\u0026mdash;to manually replace \\n with \u0026lt;br /\u0026gt; tags. It\u0026rsquo;s a well-known workaround in web dev.\nBut here\u0026rsquo;s the thing\u0026mdash;and I\u0026rsquo;m slightly embarrassed to admit I didn\u0026rsquo;t know this for way too long: Most often, you don\u0026rsquo;t need a special pipe or utility at all.\nInstead, just wrap your text in a \u0026lt;div\u0026gt; or \u0026lt;p\u0026gt; and apply the CSS property white-space, set to one of the following:\npre → Preserves whitespace and line breaks exactly as in the source.\npre-wrap → Same as pre, but text wraps when needed.\npre-line → Collapses multiple spaces but preserves line breaks.\nThat\u0026rsquo;s it. Line breaks will render wherever there\u0026rsquo;s a \\n. If you also want to preserve multiple consecutive whitespace characters (e.g., indentation in code blocks), use pre-wrap or pre. Or, of course, you could go full \u0026lt;pre\u0026gt; tag \u0026mdash; though that\u0026rsquo;s a bit less flexible from a styling/layout perspective.\nSometimes the simplest things are the easiest to overlook.\n","permalink":"https://build.ralphmayr.com/posts/43-you-dont-need-an-nl2br-pipe/","summary":"\u003cp\u003eThis one\u0026rsquo;s a pretty common hassle: You\u0026rsquo;ve got text with line breaks (encoded as \\n or sometimes \\r\\n \u0026mdash; thanks, Microsoft!), but when rendering that text on a webpage, those line breaks are nowhere to be seen. Naturally, browsers collapse \u0026lsquo;source\u0026rsquo; line breaks and ignore them.\u003c/p\u003e\n\u003cp\u003eThe usual reaction? Reach for an nl2br utility \u0026mdash; like \n\u003ca href=\"https://www.npmjs.com/package/nl2br-pipe\" target=\"_blank\" rel=\"noopener noreferrer\"\u003enl2br-pipe\u003c/a\u003e\u0026mdash;to manually replace \\n with \u0026lt;br /\u0026gt; tags. It\u0026rsquo;s a well-known workaround in web dev.\u003c/p\u003e","title":"You Don’t Need an nl2br Pipe"},{"content":"I just finished The Bright Side by Sumit Paul-Choudhury\u0026mdash;a solid deep-dive into the history, psychology, and applicability of optimism. The social science is clear: overall, optimists tend to achieve better outcomes.\nWhy? Because they act. Optimists move toward a positive vision of the future, and in doing so, often stumble upon unexpected opportunities. Pessimists, by contrast, lean toward fatalism and inaction \u0026mdash; and the world rarely arranges itself in exactly the way they\u0026rsquo;d like, anyway.\nBut: optimism has a serious downside, known as the optimism trap.\nWhen you imagine the future through rose-colored glasses, you're more likely to miss all the ways things can (and often will) go wrong.\nAs a dispositional optimist myself, I know this all too well. I\u0026rsquo;ve definitely fallen victim to the planning fallacy \u0026mdash; the tendency to wildly underestimate how long things will take.\nI once worked with a collaborator who was my complete opposite in this regard.\nWhere I saw opportunity, she saw risk.\nWhere I saw solutions, she saw reasons they wouldn\u0026rsquo;t work.\nWhere I moved to act, she urged caution and restraint.\nSo what did we do? Thankfully, we saw eye-to-eye on many other things. More often than not, we ended up reaching a truly Hegelian middle ground \u0026mdash; synthesis between thesis and antithesis. It wasn\u0026rsquo;t always fun, but it was productive. The outcomes were usually better than either charging ahead blindly or endlessly preparing for problems that might never arise.\nIf I ever actually founded a startup (instead of just hacking together quirky little apps in my basement), this is exactly the kind of founder dynamic I\u0026rsquo;d look for.\n","permalink":"https://build.ralphmayr.com/posts/42-the-optimism-trap-and-why-you-need-its-opposite/","summary":"\u003cp\u003eI just finished \n\u003ca href=\"https://ralphmayr.com/library/the-bright-side/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eThe Bright Side\u003c/a\u003e by Sumit Paul-Choudhury\u0026mdash;a solid deep-dive into the history, psychology, and applicability of \u003cem\u003eoptimism\u003c/em\u003e. The social science is clear: overall, optimists tend to achieve better outcomes.\u003c/p\u003e\n\u003cp\u003eWhy? Because they \u003cem\u003eact\u003c/em\u003e. Optimists move toward a positive vision of the future, and in doing so, often stumble upon unexpected opportunities. Pessimists, by contrast, lean toward fatalism and inaction \u0026mdash; and the world rarely arranges itself in exactly the way they\u0026rsquo;d like, anyway.\u003c/p\u003e","title":"The Optimism Trap (and Why You Need Its Opposite)"},{"content":"One of the perks of working independently on an early-stage product is flexibility \u0026mdash; but that comes with a risk: running out of steam. Like I said in my post about #pacing, it\u0026rsquo;s key not to overextend on the good days and not to check out completely when the work feels like a slog.\nOne trick I\u0026rsquo;ve found increasingly helpful as the codebase grows? Keep a backlog of quick wins:\n➡️ Small code changes\n➡️ Straightforward UI tweaks\n➡️ Refactorings\n➡️ Adding a missing unit test\nThese are tasks that don\u0026rsquo;t demand a ton of mental energy but still feel productive. Even knocking out something simple like \u0026ldquo;cleaning up how we apply drop shadows\u0026rdquo; or \u0026ldquo;writing tests for this helper function\u0026rdquo; gives a small but real boost.\nOn low-energy days, I\u0026rsquo;ll tackle one of those first. And often, once I\u0026rsquo;m in motion, I hit that flow state \u0026mdash; and suddenly the bigger tasks seem way less daunting.\nThe psychology behind this isn\u0026rsquo;t new. Greg McKeown calls it START in Effortless: \u0026ldquo;Start with a ten-minute microburst of focused activity to boost your motivation and energy.\u0026rdquo; Open source projects often have a \u0026ldquo;good first issue\u0026rdquo; backlog for exactly this reason. And when I wrote my first novel in 2020, I followed a similar strategy \u0026mdash; setting a small daily goal to turn \u0026ldquo;write 40,000 words\u0026rdquo; into something manageable.\n","permalink":"https://build.ralphmayr.com/posts/41-keep-a-list-of-low-hanging-fruit-for-days-youre-not-feeling-it/","summary":"\u003cp\u003eOne of the perks of working independently on an early-stage product is flexibility \u0026mdash; but that comes with a risk: running out of steam. Like I said in my post about #pacing, it\u0026rsquo;s key not to overextend on the good days \u003cem\u003eand\u003c/em\u003e not to check out completely when the work feels like a slog.\u003c/p\u003e\n\u003cp\u003eOne trick I\u0026rsquo;ve found increasingly helpful as the codebase grows? Keep a backlog of quick wins:\u003c/p\u003e","title":"Keep a List of ‘Low-Hanging Fruit’ for Days You’re Not Feeling It 🍒"},{"content":"It\u0026rsquo;s 2025, and I still can\u0026rsquo;t believe I have to say this \u0026mdash; but handling XML, especially in Python, remains frustratingly painful.\nTake this example: For the upcoming podcast feature of poketto.me, the app will generate a personalized podcast feed for each user, populated with text-to-speech versions of their saved content. Users can subscribe to their custom feed in any podcast app\u0026mdash;pretty handy.\nThe feed itself isn\u0026rsquo;t complex: just an XML file hosted on a web server (in my case, a GCS bucket) containing metadata and links to episode MP3s. It just needs to comply with Apple\u0026rsquo;s Podcast RSS Feed Requirements so podcast clients can parse it correctly.\nSounds simple, right?\nCreating the feed XML with Python\u0026rsquo;s built-in ElementTree is straightforward enough \u0026mdash; if all you do is initialize it:\nrss = ET.Element(\\\u0026#34;rss\\\u0026#34;, version=\\\u0026#34;2.0\\\u0026#34;, attrib={ \\\u0026#34;xmlns:itunes\\\u0026#34;: \\\u0026#34;http://www.itunes.com/dtds/podcast-1.0.dtd\\\u0026#34; }) channel = ET.SubElement(rss, \\\u0026#34;channel\\\u0026#34;) ET.SubElement(channel, \\\u0026#34;title\\\u0026#34;).text = feed_title ET.SubElement(channel, \\\u0026#34;link\\\u0026#34;).text = PODCAST_LINK ET.SubElement(channel, \\\u0026#34;description\\\u0026#34;).text = feed_title \\... xml_bytes = ET.tostring(rss, encoding=\\\u0026#39;utf-8\\\u0026#39;) xml_string = xml_bytes.decode(\\\u0026#39;utf-8\\\u0026#39;) However\u0026hellip; things get messy when you want to parse that XML later and append a new episode.\nHere\u0026rsquo;s the naive approach:\ntree = ET.ElementTree(ET.fromstring(feed_xml)) rss = tree.getroot() channel = rss.find(\\\u0026#39;channel\\\u0026#39;) \\# type: ignore ET.SubElement(channel, \\\u0026#34;item\\\u0026#34;) item = channel\\[-1\\] ET.SubElement(item, \\\u0026#34;title\\\u0026#34;).text = episode_title \\... xml_bytes = ET.tostring(rss, encoding=\\\u0026#39;utf-8\\\u0026#39;) xml_string = xml_bytes.decode(\\\u0026#39;utf-8\\\u0026#39;) What does this give you?\nExhibit A: ElementTree renames the itunes namespace to ns0.\nTechnically valid \u0026mdash; but practically useless, because most podcast clients choke on it.\nSo\u0026hellip; what\u0026rsquo;s my hacky workaround?\nBefore saving the serialized XML, I literally search and replace ns0 with itunes:\nxml_string = xml_string.replace(\\\u0026#39;ns0\\\u0026#39;, \\\u0026#39;itunes\\\u0026#39;) Ugly? Yes.\nSustainable? Definitely not.\nBut it works \u0026mdash; for now. 🤷 As long as you don\u0026rsquo;t want to have an episode titled \u0026ldquo;How if fixed the ns0-issue\u0026rdquo; of course 😅\n","permalink":"https://build.ralphmayr.com/posts/40-parsing-and-serializing-xml-is-still-a-pain-in-the-neck/","summary":"\u003cp\u003eIt\u0026rsquo;s 2025, and I still can\u0026rsquo;t believe I have to say this \u0026mdash; but handling XML, especially in Python, remains frustratingly painful.\u003c/p\u003e\n\u003cp\u003eTake this example: For the upcoming podcast feature of poketto.me, the app will generate a personalized podcast feed for each user, populated with text-to-speech versions of their saved content. Users can subscribe to their custom feed in any podcast app\u0026mdash;pretty handy.\u003c/p\u003e\n\u003cp\u003eThe feed itself isn\u0026rsquo;t complex: just an XML file hosted on a web server (in my case, a GCS bucket) containing metadata and links to episode MP3s. It just needs to comply with Apple\u0026rsquo;s \n\u003ca href=\"https://podcasters.apple.com/support/823-podcast-requirements\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ePodcast RSS Feed Requirements\u003c/a\u003e so podcast clients can parse it correctly.\u003cbr\u003e\nSounds simple, right?\u003c/p\u003e","title":"Parsing and Serializing XML Is (Still) a Pain in the Neck"},{"content":"The bigger your codebase grows, the more important it becomes to stay consistent \u0026mdash; in naming things and in how you use features of your programming language.\nTake input parameters and variables, for example. Is the ID of a Save object sometimes named save_id, other times saveId, and occasionally just id? Is the function to load it called load_save(...), get_save(...), or fetch_save(...)? Or maybe it\u0026rsquo;s load_save(...) for saves, but get_settings(...) and fetch_tag(...) elsewhere?\nIf so, confusion is only a matter of time.\nJust like I mentioned yesterday regarding the UI, it\u0026rsquo;s dangerously easy to become inconsistent. One moment of distraction, and you\u0026rsquo;ve introduced something that doesn\u0026rsquo;t follow your previous conventions.\nThis risk grows with the flexibility of your programming language.\nIn Python, for instance, you can organize your code into classes \u0026mdash; or not.\nYou can choose any casing style you like.\nYou can skip type declarations entirely or sprinkle them in randomly.\nNothing \u0026mdash; certainly not a compiler \u0026mdash; forces you to follow conventions like stricter languages (say, Java) do.\nWith poketto.me, the first large project I\u0026rsquo;ve built with Python, I realized early on that I needed to establish conventions \u0026mdash; even if they felt arbitrary at first.\nFor example:\nI use lowercase for internal module functions, but CamelCase for public ones.\nI stick with underscores for variable names, not camelCase.\nI (try to!) use type hints everywhere, including Optional[...] when applicable.\nStill\u0026hellip; it\u0026rsquo;s ridiculously easy to slip up. And the next day, you\u0026rsquo;re left wondering why your code looks inconsistent.\nThis got me thinking \u0026mdash; maybe this is a perfect use case for AI coding tools?\nCan Cursor, Copilot, or similar tools help enforce (or even detect) codebase-specific conventions automatically?\nCould they help apply a new convention \u0026mdash; like renaming all index variables from i to idx \u0026mdash; across a large codebase?\nThere might just be something there... 🤔\n","permalink":"https://build.ralphmayr.com/posts/39-consistency-beats-accuracy-part-2/","summary":"\u003cp\u003eThe bigger your codebase grows, the more important it becomes to stay consistent \u0026mdash; in naming things and in how you use features of your programming language.\u003c/p\u003e\n\u003cp\u003eTake input parameters and variables, for example. Is the ID of a \u003cstrong\u003eSave\u003c/strong\u003e object sometimes named save_id, other times saveId, and occasionally just id? Is the function to load it called load_save(...), get_save(...), or fetch_save(...)? Or maybe it\u0026rsquo;s load_save(...) for saves, but get_settings(...) and fetch_tag(...) elsewhere?\u003cbr\u003e\nIf so, confusion is only a matter of time.\u003c/p\u003e","title":"Consistency Beats Accuracy (Part 2)"},{"content":"As I add more UI-heavy functionality to poketto.me (not all of it public yet), I keep running into the same issue: it\u0026rsquo;s tempting \u0026mdash; but risky \u0026mdash; to constantly invent new UI patterns and elements.\nCase in point: On the Saves page, each saved item has a \u0026ldquo;more\u0026rdquo; menu with actions like Archive, Delete, or Edit Tags. But when I worked on the News Feed, I completely overlooked this. Instead, I gave each news item its own action bar \u0026mdash; dedicated buttons to save the item or, once saved, edit its tags. (See Exhibit A.)\nThere is a technical explanation: A News Item is different from a Save, so I figured it deserved different interactions. But realistically, users aren\u0026rsquo;t going to care about that distinction. If anything, inconsistent UI will probably confuse them.\nPlus, once a news item is saved, it\u0026rsquo;s both a news item and a save \u0026mdash; so the UI should reflect that dual role.\nMy solution (for now): I\u0026rsquo;ve added a \u0026ldquo;more\u0026rdquo; menu to all news feed entries (see Exhibit B):\n💾 If the item is already saved, the menu options closely match those on the Saves page (including Edit Tags).\n➡️If it\u0026rsquo;s unsaved, the menu shows different actions \u0026mdash; like Dismiss or Open Source.\nIt\u0026rsquo;s technically a bit more work\u0026hellip; but I\u0026rsquo;d be surprised if users don\u0026rsquo;t appreciate the consistency.\n","permalink":"https://build.ralphmayr.com/posts/38-consistency-beats-accuracy-part-1/","summary":"\u003cp\u003eAs I add more UI-heavy functionality to poketto.me (not all of it public yet), I keep running into the same issue: it\u0026rsquo;s tempting \u0026mdash; but risky \u0026mdash; to constantly invent new UI patterns and elements.\u003c/p\u003e\n\u003cp\u003eCase in point: On the \u003cstrong\u003eSaves\u003c/strong\u003e page, each saved item has a \u003cstrong\u003e\u0026ldquo;more\u0026rdquo;\u003c/strong\u003e menu with actions like \u003cem\u003eArchive\u003c/em\u003e, \u003cem\u003eDelete\u003c/em\u003e, or \u003cem\u003eEdit Tags\u003c/em\u003e. But when I worked on the \u003cstrong\u003eNews Feed\u003c/strong\u003e, I completely overlooked this. Instead, I gave each news item its own \u003cstrong\u003eaction bar\u003c/strong\u003e \u0026mdash; dedicated buttons to save the item or, once saved, edit its tags. (See Exhibit A.)\u003c/p\u003e","title":"Consistency Beats Accuracy (Part 1)"},{"content":"When publishing a new version of your Android app through the Google Play Console, you must increment the versionCode in your app\u0026rsquo;s build.gradle file. This is required even if the versionName (the human-readable version string) stays the same.\nI found this a bit confusing at first \u0026mdash; but the rule is simple: Before you build the bundle you upload to Google Play, make sure you\u0026rsquo;ve updated the versionCode. If you don\u0026rsquo;t, your upload will be rejected immediately.\n","permalink":"https://build.ralphmayr.com/posts/37-google-requires-a-new-versioncode-for-every-app-bundle-you-publish/","summary":"\u003cp\u003eWhen publishing a new version of your Android app through the Google Play Console, you \u003cem\u003emust\u003c/em\u003e increment the versionCode in your app\u0026rsquo;s build.gradle file. This is required even if the versionName (the human-readable version string) stays the same.\u003c/p\u003e\n\u003cp\u003eI found this a bit confusing at first \u0026mdash; but the rule is simple: Before you build the bundle you upload to Google Play, make sure you\u0026rsquo;ve updated the versionCode. If you don\u0026rsquo;t, your upload will be rejected immediately.\u003c/p\u003e","title":"Google Requires a New versionCode for Every App Bundle You Publish"},{"content":"As I dive deeper into poketto.me, I keep running into an increasingly tricky question:\nHow much time should I spend exploring new features \u0026mdash; and how much actually building them?\nHaving worked as a Product Owner, Manager, and Director, envisioning exciting new features comes naturally. And with poketto.me, the possibilities seem endless:\n🎧 Personalized podcasts\n🗞️ AI-curated newsfeeds\n📝 Automatic summaries\n📬 Individualized daily digests\n🖊️ Highlights, annotations, organization tools\n🔍 Full-text search and even personal knowledge management (PKM)\nBut here\u0026rsquo;s the catch:\nMy capacity to actually build these things is limited. Stacking ideas into a never-ending backlog doesn\u0026rsquo;t help if I never act on them. Worse \u0026mdash; would I even want poketto.me to become a cluttered collection of disconnected features? There\u0026rsquo;s real value in the simplicity of the core \u0026ldquo;save for later\u0026rdquo; use case.\nBalancing idea exploration with focused execution is hard \u0026mdash; especially when resources are tight. Here\u0026rsquo;s what\u0026rsquo;s been helping me decide where to focus:\n➡️ *Tie ideas back to a clear vision.* Sure, PKM features could be cool. But do they really serve poketto.me\u0026rsquo;s long-term vision? Probably not. How about podcasts or curated newsfeeds? Those are much closer to \u0026ldquo;helping people read better.\u0026rdquo;\n➡️ *Focus on learning.* Drawing from Eric Ries\u0026rsquo; Lean Startup approach:\nWhich feature would give me the most valuable insights for the least investment? What can I prototype quickly to get feedback and learn from real users?\n➡️ *Reality check your ambitions.* Some ideas sound amazing but require way more resources than I can invest right now. I allow myself to jot them down \u0026mdash; but I won\u0026rsquo;t waste energy obsessing over them. Not yet, at least.\nFor more thoughts on this: https://ralphmayr.com/posts/2022-12-08-on-frameworks/\n","permalink":"https://build.ralphmayr.com/posts/36-know-when-to-explore-and-when-to-build/","summary":"\u003cp\u003eAs I dive deeper into poketto.me, I keep running into an increasingly tricky question:\u003cbr\u003e\n\u003cstrong\u003eHow much time should I spend exploring new features \u0026mdash; and how much actually building them?\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eHaving worked as a Product Owner, Manager, and Director, envisioning exciting new features comes naturally. And with poketto.me, the possibilities seem endless:\u003cbr\u003e\n🎧 Personalized podcasts\u003cbr\u003e\n🗞️ AI-curated newsfeeds\u003cbr\u003e\n📝 Automatic summaries\u003cbr\u003e\n📬 Individualized daily digests\u003cbr\u003e\n🖊️ Highlights, annotations, organization tools\u003cbr\u003e\n🔍 Full-text search and even personal knowledge management (PKM)\u003c/p\u003e","title":"Know When to Explore — and When to Build"},{"content":"Automatic content translation has been a key feature of poketto.me from day one. Why? Because I believe there\u0026rsquo;s immense value in making content accessible to non-native speakers.\nPersonally, I\u0026rsquo;m deeply interested in developments in countries like India, Pakistan, and China \u0026mdash; but the best publications from those regions often don\u0026rsquo;t publish in English. Being able to read and compare both Dawn News (Pakistan) and the Hindustan Times (India) coverage of tensions between the two countries \u0026mdash; in English \u0026mdash; for example is fascinating.\nBut that raises the question: Which AI tool delivers the best translations?\nThe answer, unfortunately, is: It depends.\nTake the example I\u0026rsquo;ve attached. The incumbent, DeepL, arguably paraphrased the original text the most, but managed to capture many of its nuances. The general-purpose LLM contenders stayed closer to the original sentence structure but made more noticeable wording mistakes \u0026mdash; like DeepSeek\u0026rsquo;s awkward \u0026ldquo;\u0026hellip;with its agenda\u0026hellip;\u0026rdquo; phrasing. Somehow, it couldn\u0026rsquo;t grasp that the \u0026lsquo;high-tech agenda\u0026rsquo; is the point, not just any old \u0026lsquo;agenda\u0026rsquo;.\nInterestingly, none of the tools got a subtle distinction right: The phrase \u0026ldquo;zur Abstimmung\u0026rdquo; is best translated as \u0026ldquo;for review and further discussion,\u0026rdquo; not \u0026ldquo;for approval\u0026rdquo; \u0026mdash; and no tool nailed that.\n*What\u0026rsquo;s next?* For poketto.me, I\u0026rsquo;ll stick with LLM-based translations for now, despite some rough edges. But long-term, I\u0026rsquo;m considering a field test: Presenting users with two or three translation options and letting them vote for the version they feel works best.\n","permalink":"https://build.ralphmayr.com/posts/35-llm-based-translations-the-good-the-bad-and-the-ugly/","summary":"\u003cp\u003eAutomatic content translation has been a key feature of poketto.me from day one. Why? Because I believe there\u0026rsquo;s immense value in making content accessible to non-native speakers.\u003c/p\u003e\n\u003cp\u003ePersonally, I\u0026rsquo;m deeply interested in developments in countries like India, Pakistan, and China \u0026mdash; but the best publications from those regions often don\u0026rsquo;t publish in English. Being able to read and compare both \u003cem\u003eDawn News\u003c/em\u003e (Pakistan) and the \u003cem\u003eHindustan Times\u003c/em\u003e (India) coverage of tensions between the two countries \u0026mdash; in English \u0026mdash; for example is fascinating.\u003c/p\u003e","title":"LLM-Based Translations: The Good, the Bad, and the Ugly"},{"content":"Permissions on Google Cloud resources are assigned to principals. Principals, in principle 😏, are Google accounts. My personal @gmail.com address, for example, is the principal that \u0026quot;owns\u0026quot; most of my Google Cloud stuff. So far, so good.\nHowever, in some cases, I was required to delegate ownership of a resource to a principal with an @poketto.me email address \u0026mdash; a domain for which I don\u0026rsquo;t have a Google Workspace account. Consequently, these addresses aren\u0026rsquo;t recognized as regular Google Accounts. (See exhibit A)\nThe chatbots on Google\u0026rsquo;s support pages were\u0026hellip; less than helpful. But Gemini knew exactly what to do 🚀\n➡️ Sign up for a Cloud Identity account (free!) here: https://workspace.google.com/gcpidentity/signup?sku=identitybasic\n➡️ Set a password, confirm, and you\u0026rsquo;re good to go!\n","permalink":"https://build.ralphmayr.com/posts/34-cloud-idenity-is-a-secret-well-kept-by-google/","summary":"\u003cp\u003ePermissions on Google Cloud resources are assigned to principals. Principals, in principle 😏, are Google accounts. My personal @gmail.com address, for example, is the principal that \u0026quot;owns\u0026quot; most of my Google Cloud stuff. So far, so good.\u003c/p\u003e\n\u003cp\u003eHowever, in some cases, I was required to delegate ownership of a resource to a principal with an @poketto.me email address \u0026mdash; a domain for which I don\u0026rsquo;t have a Google Workspace account. Consequently, these addresses aren\u0026rsquo;t recognized as regular Google Accounts. (See exhibit A)\u003c/p\u003e","title":"‘Cloud Idenity’ is a secret well kept by Google"},{"content":"I use flex-based layouts a lot in the #Angular frontend of poketto.me. But I never realized there\u0026rsquo;s a neat little property called gap (or flex-gap) that lets you define spacing between flex items directly.\nFor the longest time, I worked around this with clumsy constructs where I\u0026rsquo;d set margins on child elements \u0026mdash; and then unset them on the last child:\n.container { display: flex; flex-direction: column; .child { margin-bottom: 1rem; \u0026amp;:last-child { margin-bottom: unset; } } } ``` When really, the world could be so much simpler: ``` .container { display: flex; flex-direction: column; gap: 1rem; } ``` No more margin hacks. ","permalink":"https://build.ralphmayr.com/posts/33-mind-the-gap-flex-gap/","summary":"\u003cp\u003eI use flex-based layouts a lot in the #Angular frontend of poketto.me. But I never realized there\u0026rsquo;s a neat little property called gap (or flex-gap) that lets you define spacing between flex items directly.\u003c/p\u003e\n\u003cp\u003eFor the longest time, I worked around this with clumsy constructs where I\u0026rsquo;d set margins on child elements \u0026mdash; and then unset them on the last child:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e.container {\n  display: flex;\n  flex-direction: column;\n\n  .child {\n    margin-bottom: 1rem;\n    \n    \u0026amp;:last-child {\n      margin-bottom: unset;\n    }\n  }\n}\n```\n\nWhen really, the world could be so much simpler:\n\n```\n.container {\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n}\n```\n\nNo more margin hacks.\n\u003c/code\u003e\u003c/pre\u003e","title":"Mind the Gap — flex-gap!"},{"content":"Working on a substantial project without real external pressure \u0026mdash; deadlines, financial run rates, etc. \u0026mdash; comes with a huge risk: you can easily run out of steam.\nWhen your only driver is your own motivation, you have to manage that resource wisely. Case in point: when I started tinkering with poketto.me, I thought I\u0026rsquo;d just replicate Pocket\u0026rsquo;s feature set and be done within four weeks. Initial success came quickly \u0026mdash; things worked the way I\u0026rsquo;d hoped, the UI kept getting better, AI coding tools helped kick-start the boilerplate work on infrastructure\u0026hellip; But then I started slacking off. I kept postponing work on the Chrome extension, the website, or the GTM strategy, and turned my attention to other side hustles instead. The initial drive I\u0026rsquo;d had simply faded.\nI got back on track by taking a page from endurance sports. As a runner, I know I can\u0026rsquo;t start a 30k run at my 10k race pace \u0026mdash; I\u0026rsquo;d crash by kilometer 15. And I definitely can\u0026rsquo;t run 20k every day for several weeks in a row. In the same vein, I began planning what a sustainable work pace for poketto.me would look like. To my delight, Greg McKeown\u0026rsquo;s Effortless echoed the exact same idea:\n👟 Commit to a minimum amount of work each day. For poketto.me, I settled on: at least one bug fix, one small enhancement, or a similar small task every day.\n👟 But also: Commit to a maximum amount of work per day so you don\u0026rsquo;t burn out. I decided on no more than three hours a day, no matter how motivated I felt.\n👟 Take time to plan and reflect. It\u0026rsquo;s easy to rush past your wins when you\u0026rsquo;re always chasing the next challenge. That\u0026rsquo;s why I spend about 30 minutes every week reflecting on what I accomplished \u0026mdash; shipped features, new user sign-ups, wins and misses on the GTM side \u0026mdash; and jotting down ideas for the week ahead.\n","permalink":"https://build.ralphmayr.com/posts/32-pace-yourself/","summary":"\u003cp\u003eWorking on a substantial project without real external pressure \u0026mdash; deadlines, financial run rates, etc. \u0026mdash; comes with a huge risk: you can easily run out of steam.\u003c/p\u003e\n\u003cp\u003eWhen your only driver is your own motivation, you have to manage that resource wisely. Case in point: when I started tinkering with poketto.me, I thought I\u0026rsquo;d just replicate Pocket\u0026rsquo;s feature set and be done within four weeks. Initial success came quickly \u0026mdash; things worked the way I\u0026rsquo;d hoped, the UI kept getting better, AI coding tools helped kick-start the boilerplate work on infrastructure\u0026hellip; But then I started slacking off. I kept postponing work on the Chrome extension, the website, or the GTM strategy, and turned my attention to other side hustles instead. The initial drive I\u0026rsquo;d had simply faded.\u003c/p\u003e","title":"Pace yourself!"},{"content":"Despite what the \u0026ldquo;God of Prompt\u0026rdquo; (sic!) or any other self-proclaimed \u0026ldquo;AI expert\u0026rdquo; is trying to tell you, none of the current AI models will replace a multi-hundred-thousand-dollar product strategy project.\nFirst of all, the people making these claims are, most likely, just trying to sell you their overpriced list of \u0026ldquo;magic\u0026rdquo; prompts \u0026mdash; and hoping for endorsement from the big AI companies or a retweet from Elon Musk.\nBut giving the AI tools the benefit of the doubt, I tried using Grok, ChatGPT, and Claude to iterate on a commercial strategy for poketto.me. The results were\u0026hellip; disappointing. Here are the main issues:\n🤯 The \u0026ldquo;knowledge\u0026rdquo; these models peddle is often just factually incorrect. They hallucinate competitors and/or their features into existence, then make bold claims about your strategy based on those confabulations. If you don\u0026rsquo;t sit down to validate each minor claim they make, you\u0026rsquo;re in deep trouble. And if you do that\u0026hellip; then why rely on the AI in the first place?\n🤯 Their reasoning is frequently overly optimistic. Not only is ChatGPT massively sycophantic (\u0026ldquo;This is a great idea!\u0026rdquo;), but none of the models offer a true reality check. If you trusted them blindly, you could easily make terrible investment decisions based on false optimism about user growth, expected revenue, and the like.\n🤯 Their recommendations are sometimes so biased it\u0026rsquo;s almost funny. One example: when I asked Grok for ideas on naming a potential \u0026ldquo;premium\u0026rdquo; price tier for poketto.me, it literally suggested \u0026ldquo;SuperPoketto\u0026rdquo; \u0026mdash; following Elon Musk\u0026rsquo;s favorite naming scheme (SuperCharger, SuperGrok, etc.).\nWhat they can do (and Grok especially) is produce nice, plausible-looking slides. And sure, some ideas might spark your curiosity. But what they can\u0026rsquo;t do is replace a person who actually does the research, reasons things through, and ultimately takes responsibility if things go wrong.\n","permalink":"https://build.ralphmayr.com/posts/31-no-ai-will-not-take-mckiney-or-bcg-out-of-business-any-day-soon/","summary":"\u003cp\u003eDespite what the \u0026ldquo;God of Prompt\u0026rdquo; (sic!) or any other self-proclaimed \u0026ldquo;AI expert\u0026rdquo; is trying to tell you, none of the current AI models will replace a multi-hundred-thousand-dollar product strategy project.\u003c/p\u003e\n\u003cp\u003eFirst of all, the people making these claims are, most likely, just trying to sell you their overpriced list of \u0026ldquo;magic\u0026rdquo; prompts \u0026mdash; and hoping for endorsement from the big AI companies or a retweet from Elon Musk.\u003c/p\u003e\n\u003cp\u003eBut giving the AI tools the benefit of the doubt, I tried using Grok, ChatGPT, and Claude to iterate on a commercial strategy for poketto.me. The results were\u0026hellip; disappointing. Here are the main issues:\u003c/p\u003e","title":"No, AI will not take McKiney or BCG out of business any day soon"},{"content":"My \u0026ldquo;user admin\u0026rdquo; section for poketto.me is quite simple: it lists all registered users, shows when they first (and most recently) used the app, and lets me assign \u0026ldquo;entitlements\u0026rdquo; (features like the experimental podcasts and newsfeeds). Works perfectly well on my development instance \u0026mdash; but in production, it kept crashing.\nTurns out: with Firebase, querying documents where a field value is `in` a list of values isn\u0026rsquo;t very smart \u0026mdash; the list may only contain up to **30 (!) values**.\nsession_docs = db.collection(\\\u0026#39;user_sessions\\\u0026#39;).where( filter=FieldFilter(\\\u0026#39;user_id\\\u0026#39;, \\\u0026#39;in\\\u0026#39;, user_ids) ).get() The other thing I learned here: the **Logs Explorer** in Google Cloud works great! But: Testing in production still sucks 😅\n","permalink":"https://build.ralphmayr.com/posts/30-firebase-as-very-peculiar-limits-or-you-dont-want-to-test-in-production/","summary":"\u003cp\u003eMy \u0026ldquo;user admin\u0026rdquo; section for poketto.me is quite simple: it lists all registered users, shows when they first (and most recently) used the app, and lets me assign \u0026ldquo;entitlements\u0026rdquo; (features like the experimental podcasts and newsfeeds). Works perfectly well on my development instance \u0026mdash; but in production, it kept crashing.\u003c/p\u003e\n\u003cp\u003eTurns out: with Firebase, querying documents where a field value is `in` a list of values isn\u0026rsquo;t very smart \u0026mdash; the list may only contain up to **30 (!) values**.\u003c/p\u003e","title":"Firebase as very peculiar limits, or: You don’t want to test in production 🙄"},{"content":"Remember when I was complaining about how hard it is to run even basic ML workloads on GCP? Turns out, Google has listened 😊 (well, probably not to me personally, but in general).\nYou can now request GPUs for Cloud Run instances in the UI as well as on the command line. That means all the hassle I went through deploying my text-to-speech service into a Docker environment running inside a preemptible VM with GPUs\u0026mdash;and then figuring out how to start, stop, and deploy the VM automatically\u0026mdash;was\u0026hellip; well, not exactly wasted, but at least: not necessary anymore.\nSo, what does that mean for poketto.me? The \u0026ldquo;personal podcast\u0026rdquo; feature just took a big step toward general availability. 🚀\nWith this setup, text-to-speech for a 1500-word article with Coqui TTS takes about 4 minutes (roughly half as long as the resulting audio file will play). That\u0026rsquo;s definitely within the range I\u0026rsquo;d expect. Plus: the audio quality and available voices are reliably good, and it comes with built-in multilingual support!\n","permalink":"https://build.ralphmayr.com/posts/29-good-things-come-to-those-who-wait/","summary":"\u003cp\u003eRemember when I was complaining about \n\u003ca href=\"../8-running-text-to-speech-in-the-cloud-is-harder-than-you-would-think-part-one/\"\u003ehow hard it is\u003c/a\u003e to run even basic ML workloads on GCP? Turns out, Google has listened 😊 (well, probably not to me personally, but in general).\u003c/p\u003e\n\u003cp\u003eYou can now request GPUs for Cloud Run instances in the UI as well as on the command line. That means all the hassle I went through deploying my text-to-speech service into a Docker environment running inside a preemptible VM with GPUs\u0026mdash;and then figuring out how to start, stop, and deploy the VM automatically\u0026mdash;was\u0026hellip; well, not exactly wasted, but at least: not necessary anymore.\u003c/p\u003e","title":"Good things come to those who wait ⏳"},{"content":"The Android status bar (that is, the small bar on top of the screen that shows the current time, the batter status, and notification icons) is really, really weird.\nIt turns out, Capacitor will, by default, make your app a \u0026ldquo;fullscreen\u0026rdquo; app: It\u0026rsquo;ll render your MainActivity (the one that hosts the WebView which in turn hosts your web app) in such a way that the status bar isn\u0026rsquo;t there at all\u0026ndash;on most devices. On some devices, however, the status bar will be visible, but transparently overlay your app 🤨And, to add insult to injury: It\u0026rsquo;ll change the font color of the status bar to white, so that if you rapp comes with a white background, all the user sees are some weird optical artifacts at the top of their screen.\nDesperately trying to work around this, I\u0026rsquo;ve even added an artificial margin \u0026ldquo;inside\u0026rdquo; my app to push the content of poketto.me down below the status bar. Turns out, that\u0026rsquo;ll only work on about half of the devices I\u0026rsquo;m testing on. On the other half? That extra margin pushes down the content too far, adding a second \u0026ldquo;empty\u0026rdquo; bar between the status bar and the app.\nAfter backtracking several steps, I\u0026rsquo;ve discovered the status-bar plugin for Capacitor, which, in theory, allows you to set the status bar to never overlay \u0026ldquo;your\u0026rdquo; WebView. In practice? Not so much. Even after tinkering with that and the windowOptOutEdgeToEdgeEnforcement property the most workable solution I found still overlays the WebView on some devices. At least, I got the status bar text color fixed 😅\nNeedless to say, if anyone encountered this before and has a solution, please give me a shout out!\n","permalink":"https://build.ralphmayr.com/posts/28-capacitor-android-status-bar/","summary":"\u003cp\u003eThe Android status bar (that is, the small bar on top of the screen that shows the current time, the batter status, and notification icons) is really, really weird.\u003c/p\u003e\n\u003cp\u003eIt turns out, Capacitor will, by default, make your app a \u0026ldquo;fullscreen\u0026rdquo; app: It\u0026rsquo;ll render your MainActivity (the one that hosts the WebView which in turn hosts your web app) in such a way that the status bar isn\u0026rsquo;t there at all\u0026ndash;on most devices. On some devices, however, the status bar will be visible, but transparently overlay your app 🤨And, to add insult to injury: It\u0026rsquo;ll change the font color of the status bar to white, so that if you rapp comes with a white background, all the user sees are some weird optical artifacts at the top of their screen.\u003c/p\u003e","title":"Capacitor + Android Status Bar = 🤯"},{"content":"It\u0026rsquo;s surprisingly hard to settle on a \u0026ldquo;fit-for-purpose\u0026rdquo; technology and tool stack for a modern SaaS / Cloud app.\nFirst of all, there are the technical decisions:\n🔀 Which frontend, backend, and persistence stack do you use? Angular vs. React, Java vs. Python, Spring Boot vs. Rails vs. Django vs. Flask, MongoDB vs. Firebase vs. MySQL vs. Postgres\u0026hellip;\n🔀 Do you run it on AWS (Amazon), GCP (Google Cloud), or Azure (Microsoft)?\n🔀 How do you handle build, deployment, automated testing, monitoring, and observability?\nBut then there\u0026rsquo;s also a myriad of options on the next level up in the tech stack:\n🔀 Usage tracking and analytics: Google Analytics, Mixpanel, Amplitude, PostHog, Hotjar, \u0026hellip;?\n🔀 Onboarding, product tours, and user guidance: Appcues, UserPilot, WalkMe, \u0026hellip;?\n🔀 Support and CRM: HubSpot, Zendesk, \u0026hellip;?\n🔀 User feedback and surveys: Google Forms, Typeform, SurveyMonkey, UserVoice, \u0026hellip;?\n🔀 Subscriptions and entitlements: Chargebee, Zuora, Recurly, \u0026hellip;?\n🔀 Feature flagging: LaunchDarkly, Flagsmith, Codemagic, Unleash, \u0026hellip;\nAlas, one could spend months researching, comparing, evaluating, and testing all of these\u0026mdash;and many combinations of them\u0026mdash;let alone the \u0026ldquo;all-in-one platforms\u0026rdquo; like Pendo or Intercom that cover many (but not all) of these categories. On the other hand, none of these tools do any rocket science, and you could build the minimum level of features they provide yourself. So, when do you \u0026ldquo;make\u0026rdquo; vs. \u0026ldquo;buy\u0026rdquo;?\nFor poketto.me, I\u0026rsquo;ve settled on a pragmatic approach: Everything I can confidently build myself in less than a day, I\u0026rsquo;ll build myself. But I\u0026rsquo;ll build it in such a way that I can swap it out for a proper external tool at some point. All else? Use an external tool\u0026mdash;ideally one with a \u0026ldquo;start free\u0026rdquo; pricing plan and a clean API that allows for easy migration if and when the need arises.\nTo give you a few examples:\n➡️ Feature flagging and entitlements: Home-grown, but can be migrated to something else quite easily\n➡️ Usage tracking: Google Analytics for now\n➡️ User feedback and marketing automation: None of the above \u0026ndash; currently, I\u0026rsquo;m sending the occasional feature update newsletter from my personal email account\n","permalink":"https://build.ralphmayr.com/posts/27-you-dont-need-to-bring-out-the-big-guns-right-away-but-its-good-to-know-them-anyway/","summary":"\u003cp\u003eIt\u0026rsquo;s surprisingly hard to settle on a \u0026ldquo;fit-for-purpose\u0026rdquo; technology and tool stack for a modern SaaS / Cloud app.\u003c/p\u003e\n\u003cp\u003eFirst of all, there are the technical decisions:\u003c/p\u003e\n\u003cp\u003e🔀 Which frontend, backend, and persistence stack do you use? Angular vs. React, Java vs. Python, Spring Boot vs. Rails vs. Django vs. Flask, MongoDB vs. Firebase vs. MySQL vs. Postgres\u0026hellip;\u003c/p\u003e\n\u003cp\u003e🔀 Do you run it on AWS (Amazon), GCP (Google Cloud), or Azure (Microsoft)?\u003c/p\u003e","title":"You don’t need to bring out the big guns right away (but it’s good to know them anyway)"},{"content":"AI is not at the core of what poketto.me does, but it helps a lot: I\u0026rsquo;m using LLMs to translate saved content and to smooth out formatting issues (especially with PDF content). Any old LLM can do these things quite well, but when it comes to pricing, none beats DeepSeek.\nWhen using their API, processing a million input tokens can be as cheap as $0.035, and a million output tokens will cost you at most $1.10. To give you an example: A typical 1,500-word essay will come down to about 2,000 tokens (input and output combined).\nCompare that to the pricing of OpenAI and Anthropic, and I\u0026rsquo;ve got a clear winner.\nHowever, this comes with one big downside (besides the fact that I\u0026rsquo;m sending content over to China for processing): latency. It can take up to several minutes for DeepSeek to finish processing a request, especially during Chinese business hours.\nBut for poketto.me, that\u0026rsquo;s not a big concern from a usability perspective: Users save content at some point, and \u0026ldquo;read it later\u0026rdquo; anyway. Whether it takes a minute or two longer to process isn\u0026rsquo;t a big deal. Plus, DeepSeek offers discounted pricing during \u0026ldquo;off-peak\u0026rdquo; hours, where the price per 1M tokens goes down by 50%.\n","permalink":"https://build.ralphmayr.com/posts/26-for-non-urgent-llm-tasks-deepseek-has-offers-great-value-for-money/","summary":"\u003cp\u003eAI is not at the core of what poketto.me does, but it helps a lot: I\u0026rsquo;m using LLMs to translate saved content and to smooth out formatting issues (especially with PDF content). Any old LLM can do these things quite well, but when it comes to pricing, none beats \u003cstrong\u003eDeepSeek\u003c/strong\u003e.\u003c/p\u003e\n\u003cp\u003eWhen using their \n\u003ca href=\"https://api-docs.deepseek.com/quick_start/pricing\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eAPI\u003c/a\u003e, processing a million input tokens can be as cheap as \u003cstrong\u003e$0.035\u003c/strong\u003e, and a million output tokens will cost you at most \u003cstrong\u003e$1.10\u003c/strong\u003e. To give you an example: A typical 1,500-word essay will come down to about 2,000 tokens (input and output combined).\u003c/p\u003e","title":"For non-urgent LLM tasks, DeepSeek has offers great value for money"},{"content":"I may sound like a broken record on this, but I\u0026rsquo;ve seen it over and over again while working with AI tools on poketto.me: Don\u0026rsquo;t trust the chatbots. Ever.\nChatGPT in particular has two immense problems: sycophancy and accuracy.\nRegarding the former: It\u0026rsquo;s trying to please you\u0026mdash;the user\u0026mdash;to the point where it feels like every response is prefaced with a compliment that\u0026rsquo;s only designed to keep you engaged. Some examples?\n\u0026quot;Excellent \u0026mdash; you\u0026rsquo;re absolutely correct.\u0026quot;\n\u0026quot;Your draft is already quite good.\u0026quot;\n\u0026quot;You're thinking exactly like someone who\u0026rsquo;s sanity-checking fingerprinting correctly.\u0026quot;\n\u0026quot;Excellent and very practical question.\u0026quot;\nAnd on the latter, here\u0026rsquo;s just one of a million examples: I\u0026rsquo;ve used ChatGPT to get feedback on new feature ideas from different perspectives (\u0026ldquo;from the perspective of a potential investor / a user / a tech journalist / \u0026hellip;\u0026rdquo;). To give credit to the tool: The responses were somewhat helpful\u0026mdash;but they were also full of factual inaccuracies.\nRegarding the \u0026ldquo;text-to-speech feature\u0026rdquo; I\u0026rsquo;m envisioning, for example, it raised the following concern:\nConcerns: TTS quality must be good to justify premium. Competition with tools like NaturalReader, Voice Dream, and even Pocket\u0026rsquo;s existing TTS.\nNot only was it unaware that Pocket isn\u0026rsquo;t around anymore (which would be excusable), but: Pocket never even had a TTS feature in the first place.\n","permalink":"https://build.ralphmayr.com/posts/25-never-trust-chatgpt/","summary":"\u003cp\u003eI may sound like a broken record on this, but I\u0026rsquo;ve seen it over and over again while working with AI tools on poketto.me: Don\u0026rsquo;t trust the chatbots. Ever.\u003c/p\u003e\n\u003cp\u003eChatGPT in particular has two immense problems: sycophancy and accuracy.\u003c/p\u003e\n\u003cp\u003eRegarding the former: It\u0026rsquo;s trying to please you\u0026mdash;the user\u0026mdash;to the point where it feels like every response is prefaced with a compliment that\u0026rsquo;s only designed to keep you engaged. Some examples?\u003c/p\u003e","title":"Never trust ChatGPT"},{"content":"I use the free version of Monosnap to record short feature videos for poketto.me\u0026mdash;the ones I post here, on X, and on Bluesky. It works great, but the default encoding produces videos that are far too large for other contexts\u0026mdash;especially email.\nRather than fiddling with different encodings, I use the command-line tool ffmpeg to scale down the videos. A typical Mac screen recording often has dimensions well over 2000×1500 pixels\u0026mdash;when half (or even a quarter) of that would easily do the job.\nHere\u0026rsquo;s the command I use (courtesy of a friendly person on Stack Exchange)\nffmpeg -i input.mp4 -vf \u0026quot;scale=trunc(iw/4)*2:trunc(ih/4)*2\u0026quot; output.mp4\n","permalink":"https://build.ralphmayr.com/posts/24-scaling-down-screen-recordings-with-ffmpeg-is-fast-easy-and-super-useful/","summary":"\u003cp\u003eI use the free version of Monosnap to record short feature videos for poketto.me\u0026mdash;the ones I post here, on X, and on Bluesky. It works great, but the default encoding produces videos that are far too large for other contexts\u0026mdash;especially email.\u003c/p\u003e\n\u003cp\u003eRather than fiddling with different encodings, I use the command-line tool ffmpeg to scale down the videos. A typical Mac screen recording often has dimensions well over 2000×1500 pixels\u0026mdash;when half (or even a quarter) of that would easily do the job.\u003c/p\u003e","title":"Scaling down screen recordings with ffmpeg is fast, easy, and super useful"},{"content":"This one\u0026rsquo;s a bit more philosophical\u0026mdash;but stay with me: There are things in life we can control, and things we can\u0026rsquo;t. That distinction lies at the heart of Stoic philosophy, most famously articulated by Epictetus in the first century BC.\nWhat does that have to do with product development?\nA lot, actually.\nWhen you\u0026rsquo;re working on a small, independent project like poketto.me, it\u0026rsquo;s easy to grow frustrated with a lack of resonance. LinkedIn posts don\u0026rsquo;t get the traction you hoped for. Journalists don\u0026rsquo;t reply. Mozilla doesn\u0026rsquo;t respond, even after you\u0026rsquo;ve tried to nudge them on all imaginable platforms,. It can feel like you\u0026rsquo;re putting something good into the world\u0026mdash;and the world is simply ignoring it.\nBut the Stoics would say: These are things outside your control.\nYou can\u0026rsquo;t control whether someone replies to your email, whether users sign up, or whether a post goes viral. These are decisions made by other people, based on their own contexts, priorities, and randomness. And so, getting emotionally attached to those outcomes\u0026mdash;outcomes you don\u0026rsquo;t control\u0026mdash;only sets you up for disappointment.\nWhat is within your control is how you show up. Are you proud of what you built? Do you find meaning in the work itself? Does it reflect your attention to detail, your curiosity, your intent?\nThat\u0026rsquo;s what I try to focus on.\nYes, I want people to use poketto.me. I want their feedback. I want to co-evolve the product with many real users. But whether that happens this week, next month, or never\u0026mdash;that\u0026rsquo;s not something I can force.\nWhat I can do is send the tenth pitch email with the same care I put into the first. I can build the next feature with the same attention to usability, performance, and style. I can enjoy the process of solving problems, writing code, and sharing what I learn.\nIf that leads to growth, amazing. If not\u0026mdash;that\u0026rsquo;s okay too. Because how could it not be?\n","permalink":"https://build.ralphmayr.com/posts/23-dont-attach-yourself-to-outcomes/","summary":"\u003cp\u003eThis one\u0026rsquo;s a bit more philosophical\u0026mdash;but stay with me: There are things in life we can control, and things we can\u0026rsquo;t. That distinction lies at the heart of Stoic philosophy, most famously articulated by Epictetus in the first century BC.\u003c/p\u003e\n\u003cp\u003eWhat does that have to do with product development?\u003c/p\u003e\n\u003cp\u003eA lot, actually.\u003c/p\u003e\n\u003cp\u003eWhen you\u0026rsquo;re working on a small, independent project like poketto.me, it\u0026rsquo;s easy to grow frustrated with a lack of resonance. LinkedIn posts don\u0026rsquo;t get the traction you hoped for. Journalists don\u0026rsquo;t reply. Mozilla doesn\u0026rsquo;t respond, even after you\u0026rsquo;ve tried to nudge them on all imaginable platforms,. It can feel like you\u0026rsquo;re putting something good into the world\u0026mdash;and the world is simply ignoring it.\u003c/p\u003e","title":"Don’t attach yourself to outcomes"},{"content":"I thought I was being clever when I implemented sign-in via email + one-time token for poketto.me:\n✅ No passwords to store\n✅ No reliance on external login providers like Google or Facebook\nAnd I had two reliable email services to send those tokens: Hetzner, via their all-inclusive web + mail hosting package I\u0026rsquo;m using for ralphmayr.com, and Zoho Mail, as a lightweight, email-only solution I\u0026rsquo;m using for poketto.me.\nOn paper, everything looked fine: The SMTP server accepted the mail, and the message was sent. Simple, right? Wrong\u0026mdash;especially when Gmail is on the receiving end.\nEmails sent via Zoho would routinely end up deep in Gmail\u0026rsquo;s spam folder, leaving users stuck hitting refresh, getting frustrated, and eventually abandoning the login process. Hetzner worked more reliably (perhaps thanks to better deliverability reputation with Google), but even there, I ran into strange throttling behavior.\nDuring development, I triggered a bunch of token emails for testing. Then\u0026hellip; they just stopped arriving. I debugged everything on my end, found no issue, gave up in frustration, went for a walk\u0026mdash;and came back to 15 tokens suddenly dumped into my inbox all at once. Gmail had queued them and released them later, for reasons known only to the algorithm.\nSo, what\u0026rsquo;s the fix?\nFor now, I\u0026rsquo;ve switched to sending tokens via Hetzner only. That means users get emails from me personally rather than the more neutral hello@poketto.me address, but that\u0026rsquo;s a trade-off I can live with. I also added a small note on the login screen to remind users to check their spam folders.\nIs this perfect? No.\nWill I move to a dedicated email delivery service in the future (for login, newsletters, notifications, etc.)? Very likely.\nBut for now? It works well enough.\n","permalink":"https://build.ralphmayr.com/posts/22-gmails-spam-filter-is-pretty-weird/","summary":"\u003cp\u003eI thought I was being clever when I implemented sign-in via email + one-time token for poketto.me:\u003c/p\u003e\n\u003cp\u003e✅ No passwords to store\u003c/p\u003e\n\u003cp\u003e✅ No reliance on external login providers like Google or Facebook\u003c/p\u003e\n\u003cp\u003eAnd I had two reliable email services to send those tokens: Hetzner, via their all-inclusive web + mail hosting package I\u0026rsquo;m using for \n\u003ca href=\"https://ralphmayr.com\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eralphmayr.com\u003c/a\u003e, and Zoho Mail, as a lightweight, email-only solution I\u0026rsquo;m using for poketto.me.\u003c/p\u003e\n\u003cp\u003eOn paper, everything looked fine: The SMTP server accepted the mail, and the message was sent. Simple, right? Wrong\u0026mdash;especially when Gmail is on the receiving end.\u003c/p\u003e","title":"GMail’s spam filter is pretty weird"},{"content":"...or LangGraph, or LlamaIndex, or RAG, or whatever new AI-hype framework is trending this week in order build an AI-powered app.\nMore often than not, these frameworks are just wrappers around basic functionality\u0026mdash;in this case, calling an API. And the layers of abstraction they introduce can make even simple things (\u0026ldquo;prompt an LLM\u0026rdquo;) feel unnecessarily complex.\nTake RAG, for example. All it really does is frontload your prompt with additional context. That\u0026rsquo;s it. In practice, it boils down to concatenating a few strings\u0026mdash;something you can do in five lines of code. But LangChain adds layer upon layer of custom methods, config objects, routing logic, etc., that often just get in the way.\nSure, these frameworks have their use cases. If your \u0026ldquo;context\u0026rdquo; is too large for your LLM\u0026rsquo;s token limit, using retrieval to send only the most relevant chunk does make sense. But with 64k+ token limits becoming standard, even that\u0026rsquo;s increasingly rare.\nFor poketto.me, I still use LangChain\u0026mdash;but only as a thin abstraction layer over the LLM APIs. It makes vendor switching (Claude ↔︎ GPT ↔︎ DeepSeek, etc.) quick and painless. That\u0026rsquo;s about the only real benefit I\u0026rsquo;ve found so far.\n","permalink":"https://build.ralphmayr.com/posts/21-no-you-dont-have-to-learn-langchain/","summary":"\u003cp\u003e...or LangGraph, or LlamaIndex, or RAG, or whatever new AI-hype framework is trending this week in order build an AI-powered app.\u003c/p\u003e\n\u003cp\u003eMore often than not, these frameworks are just \u003cem\u003ewrappers\u003c/em\u003e around basic functionality\u0026mdash;in this case, calling an API. And the layers of abstraction they introduce can make even simple things (\u0026ldquo;prompt an LLM\u0026rdquo;) feel unnecessarily complex.\u003c/p\u003e\n\u003cp\u003eTake RAG, for example. All it really does is frontload your prompt with additional context. That\u0026rsquo;s it. In practice, it boils down to concatenating a few strings\u0026mdash;something you can do in five lines of code. But LangChain adds layer upon layer of custom methods, config objects, routing logic, etc., that often just get in the way.\u003c/p\u003e","title":"No, you don’t have to learn LangChain"},{"content":"Taking good screenshots of a web or mobile app is an art in itself:\n🤨Which screens do you show?\n🤨What demo data should appear?\n🤨How much (or how little) functionality and complexity do you reveal?\nFor poketto.me (the landing page), I chose a fairly minimal set of screens. But I still wanted them to look polished\u0026mdash;whatever that means.\nTurns out, BrowserFrame.com makes this super easy:\n➡️ Upload a raw screenshot of your app\n➡️ Pick from a range of realistic browser window styles (Chrome, Safari, Edge, etc.)\n➡️ Download a slick, framed version\u0026mdash;complete with browser chrome and a subtle drop shadow\nSimple, effective, and looks like you spent way more time in Figma than you actually did.\n","permalink":"https://build.ralphmayr.com/posts/20-high-quality-screenshots-is-your-friend/","summary":"\u003cp\u003eTaking good screenshots of a web or mobile app is an art in itself:\u003c/p\u003e\n\u003cp\u003e🤨Which screens do you show?\u003cbr\u003e\n🤨What demo data should appear?\u003cbr\u003e\n🤨How much (or how little) functionality and complexity do you reveal?\u003c/p\u003e\n\u003cp\u003eFor poketto.me (the landing page), I chose a fairly minimal set of screens. But I still wanted them to look \u003cem\u003epolished\u003c/em\u003e\u0026mdash;whatever that means.\u003c/p\u003e\n\u003cp\u003eTurns out, \n\u003ca href=\"https://browserframe.com\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eBrowserFrame.com\u003c/a\u003e makes this super easy:\u003c/p\u003e\n\u003cp\u003e➡️ Upload a raw screenshot of your app\u003cbr\u003e\n➡️ Pick from a range of realistic browser window styles (Chrome, Safari, Edge, etc.)\u003cbr\u003e\n➡️ Download a slick, framed version\u0026mdash;complete with browser chrome and a subtle drop shadow\u003c/p\u003e","title":"High-quality screenshots?  is your friend! 🖼️"},{"content":"Back in my corporate days, I didn\u0026rsquo;t always give feedback the attention it deserves. But building poketto.me as a solo endeavour has reminded me just how crucial it really is.\nWorking alone has its perks: you can move fast, make bold decisions, and follow your own vision. But it also comes with pitfalls:\n➡️ You get blindsided by your own past choices\n➡️ You can waste time iterating on suboptimal ideas\n➡️ You miss what\u0026rsquo;s obvious to others\nFortunately, I\u0026rsquo;ve had some amazing people provide feedback along the way:\n📰 A thoughtful tech journalist who\u0026rsquo;s considering writing about poketto.me shared early impressions\u0026mdash;and in doing so, surfaced UX issues I was totally blind to. The tagging dialog? Made perfect sense to me\u0026mdash;but wasn\u0026rsquo;t intuitive at all to fresh eyes.\n📱 Florian Mayr, the first real power user of the Android app, flagged a ton of tiny mobile usability quirks I\u0026rsquo;d never have noticed on my own. Total gold.\n💡 Reinhold Degenfellner brought in deep, critical thinking on the entire project. Several of his ideas are now shaping new features that go way beyond the original \u0026ldquo;Pocket clone\u0026rdquo; concept.\n*Takeaway:* Even (especially!) for small, indie projects: get feedback early. Get it often. Don\u0026rsquo;t get stuck in your own head.\n","permalink":"https://build.ralphmayr.com/posts/19-feedback-is-key/","summary":"\u003cp\u003eBack in my corporate days, I didn\u0026rsquo;t always give feedback the attention it deserves. But building poketto.me as a solo endeavour has reminded me just how crucial it really is.\u003c/p\u003e\n\u003cp\u003eWorking alone has its perks: you can move fast, make bold decisions, and follow your own vision. But it also comes with pitfalls:\u003c/p\u003e\n\u003cp\u003e➡️ You get blindsided by your own past choices\u003cbr\u003e\n➡️ You can waste time iterating on suboptimal ideas\u003cbr\u003e\n➡️ You miss what\u0026rsquo;s obvious to others\u003c/p\u003e","title":"Feedback is key 🔑"},{"content":"For some reason, async communication between a web app backend and the browser causes more anxiety than it should (guilty 🙋). I\u0026rsquo;ve had my fair share of headaches with WebSocket frameworks before, but for poketto.me, I tried a much simpler stack\u0026mdash;and was pleasantly surprised:\n➡️http backend: Flask ( https://flask.palletsprojects.com/en/stable/)\n➡️websocket backend: Flask-Socketio ( https://flask-socketio.readthedocs.io/en/latest/)\n➡️websocket frontend: socketio-client ( https://www.npmjs.com/package/socket.io-client)\n🔌 Backend: Handling connections is dead simple @socketio.on(\u0026#39;connect\u0026#39;) def handle_connect(): _, user_id = authenticate() sid = request.sid connected_clients[user_id] = sid 📤 Sending data to a connected client sid = connected_clients.get(user_id)\\ if sid: socketio.emit(\u0026#39;message\u0026#39;, { \u0026#39;message_type\u0026#39;: \u0026#39;save_changed\u0026#39;, \u0026#39;data\u0026#39;: { \u0026#39;id\u0026#39;: save_id, \u0026#39;state\u0026#39;: \u0026#39;archived\u0026#39; } }, to=sid) #### **🖥️ Frontend: Receiving messages is just as easy** const socket = io(environment.WS_BASE_URL, {\nsocket.on(\u0026lsquo;message\u0026rsquo;, (message) =\u0026gt; { this.message$.next(message) });\n✅Super simple\\ ✅Works out of the box with Angular + Python ⚠️ **One caveat**: My implementation stores WebSocket sessions in memory. That means: If the backend restarts, the connected_clients dictionary is wiped. If you\\\u0026#39;re running multiple backend instances, clients may hit different ones. So, I only use WebSockets for \u0026#34;nice-to-haves,\u0026#34; like preview updates once a Save finishes processing. If it fails? No big deal---users still see something and get fresh data on the next refresh. ","permalink":"https://build.ralphmayr.com/posts/18-socket-science-isnt-rocket-science-or-websockets-flask-socketio/","summary":"\u003cp\u003eFor some reason, async communication between a web app backend and the browser causes more anxiety than it should (guilty 🙋). I\u0026rsquo;ve had my fair share of headaches with WebSocket frameworks before, but for poketto.me, I tried a much simpler stack\u0026mdash;and was pleasantly surprised:\u003c/p\u003e\n\u003cp\u003e➡️http backend: \u003cstrong\u003eFlask\u003c/strong\u003e (\n\u003ca href=\"https://flask.palletsprojects.com/en/stable/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehttps://flask.palletsprojects.com/en/stable/\u003c/a\u003e)\u003cbr\u003e\n➡️websocket backend: \u003cstrong\u003eFlask-Socketio\u003c/strong\u003e (\n\u003ca href=\"https://flask-socketio.readthedocs.io/en/latest/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehttps://flask-socketio.readthedocs.io/en/latest/\u003c/a\u003e)\u003cbr\u003e\n➡️websocket frontend: \u003cstrong\u003esocketio-client\u003c/strong\u003e (\n\u003ca href=\"https://www.npmjs.com/package/socket.io-client\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehttps://www.npmjs.com/package/socket.io-client\u003c/a\u003e)\u003c/p\u003e\n\u003ch4 id=\"-backend-handling-connections-is-dead-simple\"\u003e\u003cstrong\u003e🔌 Backend: Handling connections is dead simple\u003c/strong\u003e\u003c/h4\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e@socketio.on(\u0026#39;connect\u0026#39;)\ndef handle_connect():\n_, user_id = authenticate()\nsid = request.sid\nconnected_clients[user_id] = sid\n\u003c/code\u003e\u003c/pre\u003e\u003ch4 id=\"-sending-data-to-a-connected-client\"\u003e\u003cstrong\u003e📤 Sending data to a connected client\u003c/strong\u003e\u003c/h4\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003esid = connected_clients.get(user_id)\\\nif sid:\n  socketio.emit(\u0026#39;message\u0026#39;, {\n  \u0026#39;message_type\u0026#39;: \u0026#39;save_changed\u0026#39;,\n  \u0026#39;data\u0026#39;: {\n  \u0026#39;id\u0026#39;: save_id,\n  \u0026#39;state\u0026#39;: \u0026#39;archived\u0026#39;\n  }\n}, to=sid)\n\n#### **🖥️ Frontend: Receiving messages is just as easy**\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003econst socket = io(environment.WS_BASE_URL, {\u003c/p\u003e","title":"Socket science isn’t rocket science! 🚀 Or: WebSockets + Flask + SocketIO = ❤️"},{"content":"I\u0026rsquo;ll admit it: when I built the frontend for [poketto.me, I focused mostly on desktop browsers\u0026mdash;except for the reading experience, which was always meant to be mobile-first. But optimizing the rest of the UI for smaller screens later on? A real hassle\u0026mdash;and one that could\u0026rsquo;ve been avoided had I truly embraced mobile-first design from the start.\nHere are a few key lessons the hard way taught me:\n📱 *Thumb-friendly design matters* The Rule of Thumbs is real:\nDesign for *single-handed use*\nIncrease button sizes and tappable areas\nAvoid placing key controls in hard-to-reach corners\n🧭 *Navigation needs rethinking* Android users expect the back button to work. On the web, that\u0026rsquo;s less of a thing\u0026mdash;but on mobile, poor back navigation breaks flow. Make sure your app handles this gracefully.\n📐 *UI layout doesn\u0026rsquo;t scale automatically* My original layout had a left-hand sidebar menu\u0026mdash;totally fine on desktop, completely unworkable on mobile.\nQuick fix: I crammed key menu items into the top bar for phones. But what happens when you have more than 3\u0026ndash;4? Back to the drawing board...\nIn short: retrofitting a desktop-first app for mobile works, but it\u0026rsquo;s clunky. Designing with mobile in mind from day one? Worth the effort.\n","permalink":"https://build.ralphmayr.com/posts/17-designing-for-mobile-after-the-fact-is-painful/","summary":"\u003cp\u003eI\u0026rsquo;ll admit it: when I built the frontend for [poketto.me, I focused mostly on desktop browsers\u0026mdash;except for the reading experience, which was always meant to be mobile-first. But optimizing the rest of the UI for smaller screens later on? A \u003cstrong\u003ereal hassle\u003c/strong\u003e\u0026mdash;and one that could\u0026rsquo;ve been avoided had I truly embraced \u003cem\u003emobile-first\u003c/em\u003e design from the start.\u003c/p\u003e\n\u003cp\u003eHere are a few key lessons the hard way taught me:\u003c/p\u003e\n\u003cp\u003e📱 *\u003cem\u003eThumb-friendly design matters*\u003c/em\u003e\nThe \n\u003ca href=\"https://www.zilliondesigns.com/blog/infographics/mobile-app-design-thumb-friendly/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eRule of Thumbs\u003c/a\u003e is real:\u003c/p\u003e","title":"Designing for mobile after the fact is painful"},{"content":"As I mentioned the other day, I love Angular Material. However, I was quite surprised to discover that the matTooltip directive can negatively impact the user experience on mobile 🤯\nI noticed that users couldn\u0026rsquo;t 'drag to scroll' on poketto.me on their phones (in both the Android app and the browser) when they started to drag particular elements on the screen, such as the save title images.\nAfter a lot of analysis and debugging, I discovered that the matTooltip directive, which I use to render tooltips displaying the full save title in case it is abbreviated, sets touch-action: none; on that element. This means: On a mobile device, users can't start scrolling the page with that element as an anchor. It feels totally weird!\n✅Is there a fix for this? Yes! Adding matTooltipTouchGestures=\u0026quot;off\u0026quot; keeps the tooltip in place and fixes mobile scrolling.\n","permalink":"https://build.ralphmayr.com/posts/16-materials-tooltip-interferes-with-touch-scrolling-on-mobile/","summary":"\u003cp\u003eAs I mentioned the other day, I love Angular Material. However, I was quite surprised to discover that the matTooltip directive can negatively impact the user experience on mobile 🤯\u003c/p\u003e\n\u003cp\u003eI noticed that users couldn\u0026rsquo;t 'drag to scroll' on poketto.me on their phones (in both the Android app and the browser) when they started to drag particular elements on the screen, such as the save title images.\u003c/p\u003e\n\u003cp\u003eAfter a lot of analysis and debugging, I discovered that the matTooltip directive, which I use to render tooltips displaying the full save title in case it is abbreviated, sets touch-action: none; on that element. This means: On a mobile device, users can't start scrolling the page with that element as an anchor. It feels totally weird!\u003c/p\u003e","title":"Material’s tooltip interferes with touch-scrolling on mobile"},{"content":"When I set up my personal blog, ralphpmayr.com, years ago, I opted for a GitLab CI pipeline to build and deploy it. However, for poketto.me, I was looking for something faster, cheaper and more closely integrated with the Google Cloud ecosystem.\nI opted for Google's own CloudBuild and discovered that it integrates seamlessly with GitLab.com. In GitLab, all you need to do is create two API keys (one for read access and one for edit access), configure these in Google Cloud and CloudBuild will then be able to fetch and build any GitLab project.\nThe build scripts themselves (cloudbuild.yaml) are intuitive and offer \u0026ldquo;native\u0026rdquo; support for interacting with GCP. Attached is an example that builds my Angular front end, creates a Docker image consisting of the Python back end code plus the compiled Angular, and stores the image in my GCP project. I have a separate build script that deploys the image to a Cloud Run instance, which is even simpler.\nsteps: - name: \u0026#39;gcr.io/google.com/cloudsdktool/cloud-sdk\u0026#39; id: \u0026#39;Deploy\u0026#39; entrypoint: \u0026#39;bash\u0026#39; args: - \u0026#39;-c\u0026#39; - | gcloud run deploy poketto-app-dev \\ --image gcr.io/\\$PROJECT_ID/poketto-app \\ --region=europe-west1 \\ --platform managed \\ --allow-unauthenticated \\ --env-vars-file=env-dev.yaml options: logging: CLOUD_LOGGING_ONLY ","permalink":"https://build.ralphmayr.com/posts/15-cloud-build-beats-gitlab-ci-for-my-use-case/","summary":"\u003cp\u003eWhen I set up my personal blog, \n\u003ca href=\"https://ralphmayr.com\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eralphpmayr.com\u003c/a\u003e, years ago, I opted for a GitLab CI pipeline to build and deploy it. However, for poketto.me, I was looking for something faster, cheaper and more closely integrated with the Google Cloud ecosystem.\u003c/p\u003e\n\u003cp\u003eI opted for Google's own CloudBuild and discovered that it integrates seamlessly with GitLab.com. In GitLab, all you need to do is create two API keys (one for read access and one for edit access), configure these in Google Cloud and CloudBuild will then be able to fetch and build any GitLab project.\u003c/p\u003e","title":"Cloud Build beats GitLab CI for my use case."},{"content":"🏗️First, of course, you actually got to build your app. Then you register at the Google Play Console (and fork over $25). Then you provide your name, contact details, etc. and then the fun starts: You need official ID (drivers license, passport), proof of residence (\u0026ldquo;Meldezettel\u0026rdquo; in Austria), proof that you own an Android device, install the Google Play Console App on that device, and verify your contact phone number.\n📄Then you fill out about 5 different questionnaires about the nature of your app, all coming down to Google covering its (legal) base regarding restricted content, privacy, data, user age, etc. Is your app a Government app? Does it give financial advice? Does it collect the precise location? Do you have or need an Advertising ID? Does the app provide health advice or collect health-related personal information?\n🔐Of course, you also need to provide a contact email, a link to your terms of use and your privacy policy, short and long descriptions for the Play Store listing, release notes, etc. And, of course, screenshots: Phone screenshots, tablet screenshots, Chromebook screenshots (if you happen to have any), etc. etc., All of them have to comply with Google\u0026rsquo;s criteria in terms of size, format, orientation, etc.\n🤯 Testing requirement for new devs: You can\u0026rsquo;t just publish your app. You first need to find at least 12 testers, each of whom must install the app and keep it on their device for 14 consecutive days. Only after that will Google even consider giving you \u0026ldquo;production\u0026rdquo; access.\nI get it \u0026mdash; they have to to protect Android users from low-quality or malicious apps. But do they have to make developers feel like Odysseus on the way to Ithaca?\n","permalink":"https://build.ralphmayr.com/posts/14-the-process-to-get-an-app-into-google-play-is-byzantine/","summary":"\u003cp\u003e🏗️First, of course, you actually got to build your app. Then you register at the \u003cstrong\u003eGoogle Play Console\u003c/strong\u003e (and fork over $25). Then you provide your name, contact details, etc. and then the fun starts: You need official ID (drivers license, passport), proof of residence (\u0026ldquo;Meldezettel\u0026rdquo; in Austria), proof that you own an Android device, install the Google Play Console App on that device, and verify your contact phone number.\u003c/p\u003e","title":"The process to get an app into Google Play is… byzantine"},{"content":"I\u0026rsquo;ve had my ups and downs with \u0026ldquo;Sign in with Google\u0026rdquo;:\n⬆️It\u0026rsquo;s simple and works well on the web\n⬇️It\u0026rsquo;s a complete hassle inside a hosted WebView in a native mobile app\nBut here\u0026rsquo;s something really funny: I local clock is ahead of Google's \u0026mdash; sometimes by as little as a few milliseconds \u0026ndash; the sign in call will fail with \u0026ldquo;Token used too early.\u0026rdquo; 🤯\n✅ Solution: resync your system clock (in my case, my MacBook with Apple's time server).\nTiny offset → big headache. ⏱️\n","permalink":"https://build.ralphmayr.com/posts/13-token-used-too-early-the-weirdest-google-sign-in-error/","summary":"\u003cp\u003eI\u0026rsquo;ve had my ups and downs with \u0026ldquo;Sign in with Google\u0026rdquo;:\u003c/p\u003e\n\u003cp\u003e⬆️It\u0026rsquo;s simple and works well on the web\u003c/p\u003e\n\u003cp\u003e⬇️It\u0026rsquo;s a complete hassle inside a hosted WebView in a native mobile app\u003c/p\u003e\n\u003cp\u003eBut here\u0026rsquo;s something really funny: I local clock is \u003cem\u003eahead\u003c/em\u003e of Google's \u0026mdash; sometimes by as little as a few milliseconds \u0026ndash; the sign in call will fail with \u0026ldquo;Token used too early.\u0026rdquo; 🤯\u003c/p\u003e\n\u003cp\u003e✅ Solution: resync your system clock (in my case, my MacBook with Apple's time server).\u003c/p\u003e","title":"\"Token used too early\" — the weirdest Google Sign-In error."},{"content":"Even without relying on the utilities provided by Capacitor, you can easily pass data back and forth between your web app and its native counterpart:\n➡️In JavaScript / TypeScript you can register global functions on the window object. The native code can call these via executeJavaScript() on the WebView.\n➡️In the other direction, the native code can register a JavaScript interface on the\nWebView:\nwebView.addJavascriptInterface(new Object() { @JavascriptInterface public void onSessionId(String value) { SaveUrlHandler.instance.setSessionId(value); } @JavascriptInterface public void closeApp() { MainActivity.this.finish(); } }, \u0026#34;Android\u0026#34;); which the JavaScript / TypeScript code can call whenever it wants:\nhandleBackButton() { if (this.location.isCurrentPathEqualTo(\u0026#39;/\u0026#39;)) { if ((window as any)[\u0026#39;Android\u0026#39;]) { (window as any)[\u0026#39;Android\u0026#39;].closeApp(); } ... } ","permalink":"https://build.ralphmayr.com/posts/12-webview-native-app-communication-is-easier-than-you-think/","summary":"\u003cp\u003eEven without relying on the utilities provided by Capacitor, you can easily pass data back and forth between your web app and its native counterpart:\u003c/p\u003e\n\u003cp\u003e➡️In JavaScript / TypeScript you can register global functions on the window object. The native code can call these via executeJavaScript() on the WebView.\u003c/p\u003e\n\u003cp\u003e➡️In the other direction, the native code can register a JavaScript interface on the\u003c/p\u003e\n\u003cp\u003eWebView:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ewebView.addJavascriptInterface(new Object() {\n  @JavascriptInterface\n  public void onSessionId(String value) {\n    SaveUrlHandler.instance.setSessionId(value);\n  }\n\n  @JavascriptInterface\n  public void closeApp() {\n    MainActivity.this.finish();\n  }\n}, \u0026#34;Android\u0026#34;);\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003ewhich the JavaScript / TypeScript code can call whenever it wants:\u003c/p\u003e","title":"WebView ↔ Native app communication is easier than you think."},{"content":"\u0026ldquo;Build it and they will come\u0026rdquo; is allegedly what God told Noah when he wondered how all the animals would find the ark.\nAs builders, we often fall into the same trap \u0026mdash; assuming that once the product is done, users will magically appear. And when they don\u0026rsquo;t, it\u0026rsquo;s not just disappointing \u0026mdash; it\u0026rsquo;s exhausting and demotivating.\nTurns out, even launching something small (like poketto.me) requires way more go-to-market work than expected. And honestly? That part\u0026rsquo;s less fun than building.\nHere\u0026rsquo;s just a partial list of what I\u0026rsquo;ve wrestled with over the last few weeks:\n➡️ *Value propositions:* What resonates better? \u0026quot;A worthy successor to Mozilla's Pocket\u0026quot; vs. \u0026quot;Mobile-friendly PDF reading\u0026quot;? Does privacy-centric still matter to users? Does anyone care that their data is stored in Europe? What about indie tools like poketto.me as a counterweight to centralization by Google, Meta, and OpenAI?\n➡️ *Copywriting:* The landing page layout came from an AI \u0026mdash; but the tone, structure, and wording? All hand-tuned.\n➡️ *Press kit:* Screenshots, demo videos, feature descriptions in different formats and voices. AI helps, but not without serious fine-tuning.\n➡️ *Store listings:* From the Chrome Web Store to Google Play to AlternativeTo, each platform has different requirements, formats, and editorial rules. Even screenshots need to be tailored.\n➡️ *Social media:* Setting up and maintaining accounts on X, Bluesky, etc. is work. I\u0026rsquo;ve not done nearly enough of it yet.\n➡️ *Journalists 🙄* Even getting a free, useful tool in front of someone with a platform is really hard. Unless you\u0026rsquo;re in the club, it\u0026rsquo;s often radio silence. I must have sent 20+ emails, LinkedIn and DMs on X and Bluesky over the last weeks, and got literally zero responses. I guess the only healthy approach to this is Stoicism. Epictetus would say \u0026ldquo;Focus on what\u0026rsquo;s under your control,\u0026rdquo; and, alas, tech journalists are not under my control.\nLesson learned:\nBuilding the ark is just step one.\nGetting animals to board is a whole separate game.\n","permalink":"https://build.ralphmayr.com/posts/11-the-noah-principle-still-doesnt-work/","summary":"\u003cp\u003e\u003cem\u003e\u0026ldquo;Build it and they will come\u0026rdquo; is\u003c/em\u003e allegedly what God told Noah when he wondered how all the animals would find the ark.\u003c/p\u003e\n\u003cp\u003eAs builders, we often fall into the same trap \u0026mdash; assuming that once the product is done, users will magically appear. And when they don\u0026rsquo;t, it\u0026rsquo;s not just disappointing \u0026mdash; it\u0026rsquo;s exhausting and demotivating.\u003c/p\u003e\n\u003cp\u003eTurns out, even launching something small (like poketto.me) requires way more go-to-market work than expected. And honestly? That part\u0026rsquo;s less fun than building.\u003c/p\u003e","title":"The Noah-principle (still) doesn’t work"},{"content":"So, after finally setting up a dedicated virtual machine (VM) to run my text-to-speech workloads and wiring up all the build and deployment scripts, I got a bit excited. Could I reduce the TTS latency even further if the VM had GPU power?\nIn theory: Yes. In practice: Google doesn't give you access to their GPUs straight away. There\u0026rsquo;s a special quota setting for VM instances with GPUs, and by default that\u0026rsquo;s set to zero. As a regular user, you cannot increase this without contacting Google Cloud Support.\nIt's not exactly 'self-service', but it's good to know before you start training large models.\n","permalink":"https://build.ralphmayr.com/posts/10-running-text-to-speech-in-the-cloud-is-harder-than-you-would-think-part-three/","summary":"\u003cp\u003eSo, after finally setting up a dedicated virtual machine (VM) to run my text-to-speech workloads and wiring up all the build and deployment scripts, I got a bit excited. Could I reduce the TTS latency even further if the VM had GPU power?\u003c/p\u003e\n\u003cp\u003eIn theory: Yes. In practice: Google doesn't give you access to their GPUs straight away. There\u0026rsquo;s a special quota setting for VM instances with GPUs, and by default that\u0026rsquo;s set to zero. As a regular user, you cannot increase this without contacting Google Cloud Support.\u003c/p\u003e","title":"Running text-to-speech in the #Cloud is harder than you would think (part three)"},{"content":"Do you remember when I mentioned the difficulty of running 🐸 CoquiTTS in the cloud yesterday? My first experiment was to run it directly in my Cloud Run backend service. In theory, this could have worked, but you'll never guess why it failed in practice.\nx86 CPUs. Really. Like the ones we had in our computers in the 90s. How did I figure this out? After taking a horribly long time to start up, the TTS service failed with a message saying that it was running on an 'incompatible' CPU architecture. Specifically, 32-bit x86 CPUs.\nOf course, for many workloads that people normally run in Cloud Run, this wouldn't matter at all. But for TTS and other ML-heavy workloads it does matter.\nIt should be obvious by now that this idea was a non-starter. Coming up tomorrow: More about the unexpected reason why this still didn't work out of the box when using a dedicated preemptive VM!\n","permalink":"https://build.ralphmayr.com/posts/9-running-text-to-speech-in-the-cloud-is-harder-than-you-would-think-part-two/","summary":"\u003cp\u003eDo you remember when I mentioned the difficulty of running 🐸 CoquiTTS in the cloud yesterday? My first experiment was to run it directly in my Cloud Run backend service. In theory, this could have worked, but you'll never guess why it failed in practice.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003ex86 CPUs\u003c/strong\u003e. Really. Like the ones we had in our computers in the 90s. How did I figure this out? After taking a horribly long time to start up, the TTS service failed with a message saying that it was running on an 'incompatible' CPU architecture. Specifically, 32-bit x86 CPUs.\u003c/p\u003e","title":"Running text-to-speech in the #Cloud is harder than you would think (part two)"},{"content":"For the podcast automation feature that I\u0026rsquo;m planning for a future version of poketto.me, I\u0026rsquo;ve been experimenting with various text-to-speech solutions. The easiest and highest-quality approach would have been the ElevenLabs API. However, considering the \u0026ldquo;throwaway\u0026rdquo; nature of these audio files \u0026ndash; most of which would only be listened to once by one person \u0026ndash; and the cost structure that this would introduce, I desperately need a cheaper approach.\nThe Python library 🐸 CoquiTTS is pretty awesome: There are many different models to choose from, ranging from 'super low latency' to 'high quality' (including voice cloning). Therefore, poketto.me users could choose from many different voices, and from a commercial perspective, I could set different price points for different levels of quality and latency. However, they all require significant computing power to function.\nBeing very naive about this initially, I tried to run CoquiTTS directly in a Cloud Run instance. After that (predictably) failed (more on this tomorrow!), I reworked the architecture so that these workloads would run in a dedicated preemptive VM.\nHowever, integrating this with the rest of my cloud infrastructure wasn\u0026rsquo;t easy:\n👷I set up a different Cloud Build for the text-to-speech service.\n⛅️This service isn't deployed to Cloud Run, but is instead pushed to my Artifact Registry.\n📄A separate deployment script creates the text-to-speech VM.\n🐳The VM's startup script installs Docker, then pulls and runs the image from the Artifact Registry.\n🚀 As the VM is preemptive for cost reasons, it needs a static IP address, and the actual 'main' poketto.me backend needs to be aware of this and kickstart the VM in case it's not running.\nConclusion? TTS in the cloud is harder than you'd think!\n","permalink":"https://build.ralphmayr.com/posts/8-running-text-to-speech-in-the-cloud-is-harder-than-you-would-think-part-one/","summary":"\u003cp\u003eFor the podcast automation feature that I\u0026rsquo;m planning for a future version of poketto.me, I\u0026rsquo;ve been experimenting with various text-to-speech solutions. The easiest and highest-quality approach would have been the ElevenLabs API. However, considering the \u0026ldquo;throwaway\u0026rdquo; nature of these audio files \u0026ndash; most of which would only be listened to once by one person \u0026ndash; and the cost structure that this would introduce, I desperately need a cheaper approach.\u003c/p\u003e\n\u003cp\u003eThe Python library 🐸 CoquiTTS is pretty awesome: There are many different models to choose from, ranging from 'super low latency' to 'high quality' (including voice cloning). Therefore, poketto.me users could choose from many different voices, and from a commercial perspective, I could set different price points for different levels of quality and latency. However, they all require significant computing power to function.\u003c/p\u003e","title":"Running text-to-speech in the #Cloud is harder than you would think (part one)"},{"content":"For reasons outlined in yesterday's post, I had to switch poketto.me from #CloudSQL (MySQL) to a completely different database architecture: Firebase 🔥\nAt that stage, the Python backend code base wasn\u0026rsquo;t huge, but it was already fairly substantial. It included CRUD operations for several entities, as well as some basic lookup logic. Rewriting the whole thing would have taken me at least half a day.\nInstead, I asked my good friend #Claude to take care of things. And, to my surprise, the result worked straight away! 🎁The \u0026ldquo;dorp in\u0026rdquo; replacement generated by the AI immediately passed my unit tests, and also the chatbot\u0026rsquo;s instructions for how to set up and configure #Firebase were actually useful.\nHowever, there are a few caveats to bear in mind:\n➡️We're talking about a comparatively small codebase here \u0026ndash; the original Python file was maybe 500 lines long.\n➡️I designed the architecture upfront with basic 'separation of concerns' in mind. Therefore, the database layer was neatly separated from any business logic. Therefore, all Claude had to do was understand the input and output requirements of a few Python functions and recreate their logic using a different database API.\n➡️Automated tests matter; I wouldn\u0026rsquo;t blindly trust #AI-generated code here, but with a basic testing harness in place\u0026hellip; What could possibly go wrong? 🤷\nSumming up: What can we learn from this? #Refactoring, #rewriting and potentially upgrading legacy code bases could be an area in which AI excels! It could also save valuable human developers countless hours, allowing them to focus on more engaging work.\n","permalink":"https://build.ralphmayr.com/posts/7-refactoring-legacy-code-let-the-ai-handle-it/","summary":"\u003cp\u003eFor reasons outlined in yesterday's post, I had to switch poketto.me from #CloudSQL (MySQL) to a completely different database architecture: Firebase 🔥\u003c/p\u003e\n\u003cp\u003eAt that stage, the Python backend code base wasn\u0026rsquo;t huge, but it was already fairly substantial. It included CRUD operations for several entities, as well as some basic lookup logic. Rewriting the whole thing would have taken me at least half a day.\u003c/p\u003e\n\u003cp\u003eInstead, I asked my good friend #Claude to take care of things. And, to my surprise, the result worked straight away! 🎁The \u0026ldquo;dorp in\u0026rdquo; replacement generated by the AI immediately passed my unit tests, and also the chatbot\u0026rsquo;s instructions for how to set up and configure #Firebase were actually useful.\u003c/p\u003e","title":"Refactoring “legacy” code? Let the AI handle it!"},{"content":"When I started setting up the cloud infrastructure for poketto.me, I didn\u0026rsquo;t give much thought to costs. I thought it was such a small project that it just wouldn\u0026rsquo;t matter. I launched a #CloudSQL (MySQL) database with pretty much the default settings and was quite happy with it \u0026ndash; until I checked the billing dashboard a couple of days later and realised that I was already spending almost €4 per day on the database alone. 120 euros per month just for a few MySQL tables? That couldn\u0026rsquo;t be right.\n💰But even after scaling it down to the bare minimum, I still wouldn\u0026rsquo;t get the costs below €40 or €50 per month.\n🔥 What did I do? I switched to #Firebase and am still running on their 'free' tier. Even if storage size and latency requirements demand scaling up, it would only cost a few euros a month \u0026ndash; not over 100 euros.\nOh, and, how did the migration go? Well, more on that tomorrow! 🚀\n","permalink":"https://build.ralphmayr.com/posts/6-cloudsql-is-prohibitively-expensive-at-least-for-small-projects/","summary":"\u003cp\u003eWhen I started setting up the cloud infrastructure for \u003cstrong\u003epoketto.me\u003c/strong\u003e, I didn\u0026rsquo;t give much thought to costs. I thought it was such a small project that it just wouldn\u0026rsquo;t matter. I launched a #\u003cstrong\u003eCloudSQL\u003c/strong\u003e (MySQL) database with pretty much the default settings and was quite happy with it \u0026ndash; until I checked the billing dashboard a couple of days later and realised that I was already spending almost €4 per day on the database alone. 120 euros per month just for a few MySQL tables? That couldn\u0026rsquo;t be right.\u003c/p\u003e","title":"CloudSQL is prohibitively expensive (at least for small projects)"},{"content":"#Capacitor comes with a user-friendly command line interface. To add a new mobile platform to your project, simply run \u0026ldquo;npx cap add [android | ios]\u0026rdquo;. And to remove one? Exactly \u0026mdash; you guessed it: 'npx cap remove...' But: That command isn\u0026rsquo;t implemented \u0026ndash; for understandable reasons. The interesting thing is, though, that it's \u0026quot;plausible\u0026quot; that it would be there, right?. So it's not surprising that #Claude insists it exists.\nThis once again highlights a major issue with LLMs that I just can\u0026rsquo;t shut up about: Just because what the chatbot says sounds 'plausible' doesn't mean it's correct. In the case of AI-assisted coding, that\u0026rsquo;s not such a big deal \u0026ndash; you, the developer, will eventually realise that the AI was wrong. But what about the many other use cases where we blindly trust the AI and put whatever it says into action? 🤔\nBut back to the topic: For reference, if you ever need to remove a platform in Capacitor:\n➡️ Do it manually (just delete the respective directory).\n➡️ Or, if you\u0026rsquo;re in the early stages of your work, just delete the whole project and initialise it again.\n","permalink":"https://build.ralphmayr.com/posts/5-theres-no-npx-cap-remove/","summary":"\u003cp\u003e#Capacitor comes with a user-friendly command line interface. To add a new mobile platform to your project, simply run \u0026ldquo;\u003cstrong\u003enpx cap add [android | ios]\u003c/strong\u003e\u0026rdquo;. And to remove one? Exactly \u0026mdash; you guessed it: \u003cstrong\u003e'npx cap remove\u003c/strong\u003e...' But: That command isn\u0026rsquo;t implemented \u0026ndash; for understandable reasons. The interesting thing is, though, that it's \u0026quot;plausible\u0026quot; that it would be there, right?. So it's not surprising that #Claude insists it exists.\u003c/p\u003e\n\u003cp\u003eThis once again highlights a major issue with LLMs that I just can\u0026rsquo;t shut up about: Just because what the chatbot says sounds 'plausible' doesn't mean it's correct. In the case of AI-assisted coding, that\u0026rsquo;s not such a big deal \u0026ndash; you, the developer, will eventually realise that the AI was wrong. But what about the many other use cases where we blindly trust the AI and put whatever it says into action? 🤔\u003c/p\u003e","title":"There’s no “npx cap remove” 🤦‍♂️"},{"content":"Put simply, serving web requests properly in Python is not easy. poketto.me uses a fairly basic off-the-shelf stack (#Flask as a web framework and #SocketIO for websocket communication), and you would never guess the issues you could encounter with it. For starters, Flask comes with a built-in web server (\u0026ldquo;werkzeug\u0026rdquo;), which is convenient for development, but absolutely not for production (it even warns you in bright red).\n🧵It runs on a single thread \u0026ndash; I'm not kidding. This means it can only handle one web request at a time. If that request involves any actual work, such as extracting web content, it cannot handle any other requests at the same time.\n🦄I realised this fairly late on, but was confident that I could easily fix it. My AI assistants agreed that gunicorn ( https://gunicorn.org/) was the best choice for production, but that\u0026rsquo;s where the trouble started. Mind you, adding the dependency and changing my Dockerfile for the backend service would have been easy enough:\nCMD [\u0026#34;gunicorn\u0026#34;, \u0026#34;-w\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;--threads\u0026#34;, \u0026#34;16\u0026#34;, \u0026#34;--timeout=30\u0026#34;, \u0026#34;--bind=0.0.0.0:8080\u0026#34;, \u0026#34;main:app\u0026#34;]. However, the AI insisted that I use either eventlet or gevent as the 'worker mode', and, in an almost farcical manner, suggested that I use both. Needless to say, don't try this at home! It won't work:\nsocketio = SocketIO(app, cors_allowed_origins=\u0026#34;*\u0026#34;, async_mode=\u0026#34;**gevent**\u0026#34;) gunicorn --worker-class **eventlet** -w 1 --bind 0.0.0.0:8080 main:app After some trial and error \u0026mdash; which culminated in Claud recommending that I add an event queue, Redis, and a lot of other things to my app \u0026mdash; I figured it out on my own. Just use threading. Simple as that.\nsocketio = SocketIO(app, cors_allowed_origins=\u0026#34;*\u0026#34;, async_mode=\u0026#34;threading\u0026#34;) gunicorn -w 1 --threads 8 --bind 0.0.0.0:8080 main:app Of course, this approach also has its limitations: You can only have one worker process (otherwise you\u0026rsquo;d lose the affinity needed for SocketIO to communicate with clients via the web socket), and eight threads for now. Nevertheless, this is an improvement on one thread and is most likely sufficient for the scale I\u0026rsquo;m envisaging for poketto.me. And if not, this at least to allows for the good old KIWI principle to kick in: \u0026ldquo;kill it with iron,\u0026rdquo; in the sense that you can scale up the hardware (such as switching to larger Cloud Rund instances with more CPUs) to match demand.\nNevertheless, one final point to note is that you can\u0026rsquo;t easily debug this setup. In other words, you can\u0026rsquo;t simply run gunicorn as the entry point for your app in your launch.json file in Visual Studio Code and expect breakpoints to work, for example. For local debugging, I\u0026rsquo;m still using the tried-and-tested 'werkzeug'. This is risky, of course: what if something breaks (due to a subtle change in websocket communication, for example) that only occurs with gunicorn and that I can\u0026rsquo;t reproduce with werkzeug? It doesn't exactly inspire confidence and reminds me of a category of issues that the Enterprise Java community faced \u0026ndash; and solved \u0026ndash; about 20 years ago 😅\n","permalink":"https://build.ralphmayr.com/posts/4-multi-threaded-webservers-in-python-a-rabbit-hole-you-dont-want-to-get-into/","summary":"\u003cp\u003ePut simply, serving web requests properly in Python is not easy. poketto.me uses a fairly basic off-the-shelf stack (#\u003cstrong\u003eFlask\u003c/strong\u003e as a web framework and #\u003cstrong\u003eSocketIO\u003c/strong\u003e for websocket communication), and you would never guess the issues you could encounter with it. For starters, Flask comes with a built-in web server (\u0026ldquo;werkzeug\u0026rdquo;), which is convenient for development, but absolutely not for production (it even warns you in bright red).\u003c/p\u003e\n\u003cp\u003e🧵It runs on a single thread \u0026ndash; I'm not kidding. This means it can only handle one web request at a time. If that request involves any actual work, such as extracting web content, it cannot handle any other requests at the same time.\u003c/p\u003e","title":"Multi-threaded webservers in Python: A rabbit hole you don’t want to get into."},{"content":"To my surprise, building a Chrome Extension is technically quite straightforward. You need:\nA bit of HTML (the popup UI)\nA bit of JavaScript (your logic)\nAnd a $5 one-time fee to publish in the Chrome Web Store\nThe Chrome Extensions API is pretty minimal, but it covers all the basics:\n✅ You can persist data (e.g. auth tokens)\n✅ You can query open tabs, grab the current URL\n✅ You can even inject JavaScript into pages \u0026mdash; though this last bit triggers stricter review from Google, especially if you request broad access (like \u0026quot;\u0026lt;all_urls\u0026gt;\u0026quot;).\nFor poketto.me, I scoped this down to just my own domain, so the extension can piggyback on the app\u0026rsquo;s authentication.\nWhat helped a lot: Claude. With a simple prompt, it generated a working boilerplate extension \u0026mdash; no frills, just plain HTML + JS.\nSince the main app is built with Angular, there\u0026rsquo;s not much reuse between codebases. Having the AI create a minimal standalone version of the \u0026ldquo;Save\u0026hellip;\u0026rdquo; workflow saved me hours.\n","permalink":"https://build.ralphmayr.com/posts/3-building-a-chrome-extension-is-easier-than-i-thought-but-still-a-bit-of-a-hassle/","summary":"\u003cp\u003eTo my surprise, building a Chrome Extension is \u003cem\u003etechnically\u003c/em\u003e quite straightforward. You need:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003eA bit of HTML (the popup UI)\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003eA bit of JavaScript (your logic)\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003eAnd a $5 one-time fee to publish in the Chrome Web Store\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThe \n\u003ca href=\"https://developer.chrome.com/docs/extensions/reference/api\" target=\"_blank\" rel=\"noopener noreferrer\"\u003eChrome Extensions API\u003c/a\u003e is pretty minimal, but it covers all the basics:\u003c/p\u003e\n\u003cp\u003e✅ You can persist data (e.g. auth tokens)\u003cbr\u003e\n✅ You can query open tabs, grab the current URL\u003cbr\u003e\n✅ You can even inject JavaScript into pages \u0026mdash; though this last bit triggers stricter review from Google, especially if you request broad access (like \u0026quot;\u0026lt;all_urls\u0026gt;\u0026quot;).\u003c/p\u003e","title":"Building a Chrome Extension is easier than I thought — but still a bit of a hassle"},{"content":"You can talk about autonomous #AIAgents roaming the web and performing all kinds of tasks 'just as a human would' as much as you like, but technically, some of the very basics are still lacking. For example, there is no free, easy-to-use, off-the-shelf solution for extracting web content.\nHere\u0026rsquo;s what I mean: Think of poketto.me as a very basic 'agent': you tell it to save a URL, and then it talks to the website 'on your behalf' to access its content. For this use case, the Newspaper3k Python library is pretty good: it teases out structured metadata, but occasionally misses basic things like the content language. To retrieve the actual content, Trafilatura ( https://github.com/adbar/trafilatura) appears to be the best option at the moment. However, even that doesn't work well with all sites. For edge cases, I actually had to fall back on parsing the raw HTML myself using Beautiful Soup ( https://beautiful-soup-4.readthedocs.io/en/latest/). (And yes, I\u0026rsquo;m sending that through an LLM later to streamline the content so all the tiny formatting issues Trafilatura introduces get smoothed out again.)\nGiven the disproportionate hassle required for this tiny use case, what would be needed to enable people to actually build \u0026ldquo;agents\u0026rdquo; (AI or plain automation-based) that can interact with the web autonomously, reliably and meaningfully? One of two things:\nEither we build these 'agents' in a solid and robust manner, from the ground up; for performance reasons they would primarily interact with other sites on a protocol level (like poketto.me is doing today). However, when they encounter an obstacle, they would simulate a real browser (e.g. a headless Chrome automated with #Selenium). However, this would of course run into authentication and authorization issues. Suppose the user has a subscription to the New York Times. How would the agent safely and reliably obtain these credentials so that it can use them to sign in and access the article that the user wanted to save? And how do we ensure that the agent doesn\u0026rsquo;t use these credentials to access the site on behalf of another user? That\u0026rsquo;s where (2) comes in.\nIn their recent AI x Crypto Crossovers post, the folks at a16z proposed something quite interesting: A blockchain-based infrastructure to govern interactions between agents, third-party sites and end users. It's still an uncertain idea, but ultimately, if our goal is to create real, functioning, reliable agents, this would be a significant step in that direction.\n","permalink":"https://build.ralphmayr.com/posts/2-extracting-web-content-is-still-messy/","summary":"\u003cp\u003eYou can talk about autonomous #AIAgents roaming the web and performing all kinds of tasks 'just as a human would' as much as you like, but technically, some of the very basics are still lacking. For example, there is no free, easy-to-use, off-the-shelf solution for extracting web content.\u003c/p\u003e\n\u003cp\u003eHere\u0026rsquo;s what I mean: Think of poketto.me as a very basic 'agent': you tell it to save a URL, and then it talks to the website 'on your behalf' to access its content. For this use case, the \u003cstrong\u003eNewspaper3k\u003c/strong\u003e Python library is pretty good: it teases out structured metadata, but occasionally misses basic things like the content language. To retrieve the actual content, \u003cstrong\u003eTrafilatura\u003c/strong\u003e (\n\u003ca href=\"https://github.com/adbar/trafilatura\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehttps://github.com/adbar/trafilatura\u003c/a\u003e) appears to be the best option at the moment. However, even that doesn't work well with all sites. For edge cases, I actually had to fall back on parsing the raw HTML myself using Beautiful Soup (\n\u003ca href=\"https://beautiful-soup-4.readthedocs.io/en/latest/%29\" target=\"_blank\" rel=\"noopener noreferrer\"\u003ehttps://beautiful-soup-4.readthedocs.io/en/latest/)\u003c/a\u003e. (And yes, I\u0026rsquo;m sending that through an LLM later to streamline the content so all the tiny formatting issues Trafilatura introduces get smoothed out again.)\u003c/p\u003e","title":"Extracting web content is still… messy."},{"content":"Remember when I talked about \u0026ldquo;Sign in with Google?\u0026rdquo; That works really well on the web, but: Once you\u0026rsquo;re wrapping your web app into a native mobile app, things turn very ugly very soon. In a hosted WebView, Google won\u0026rsquo;t let you render the sign in button to begin with \u0026ndash; unless you override the WebView\u0026rsquo;s user agent string. After that, you\u0026rsquo;re still screwed if you rely on the default sign in workflow as this would open a popup window from which the user then can\u0026rsquo;t navigate back into your app.\nSo, how to proceed? You could switch to the redirect-based sign in flow, but: Then Google calls back to your server (rather than the client) after sign in is complete. Hence, you have to rework your authentication logic and you can\u0026rsquo;t easily test this locally.\nBut Capacitor ( https://capacitorjs.com/) offers social sign in plugins, no?\n👉 In my experience: most don\u0026rsquo;t work reliably out of the box.\n👉 If you need rock-solid auth: go native for Android/iOS.\n🔐 What did I do instead? Disabled \u0026quot;Sign in with Google\u0026quot; in the Android app -- for now -- in favor of my own \u0026quot;Continue with email...\u0026quot; implementation 😅\n","permalink":"https://build.ralphmayr.com/posts/1-hybrid-apps-social-logins-tread-carefully/","summary":"\u003cp\u003eRemember when I talked about \u0026ldquo;Sign in with Google?\u0026rdquo; That works really well on the web, but: Once you\u0026rsquo;re wrapping your web app into a native mobile app, things turn very ugly very soon. In a hosted WebView, Google won\u0026rsquo;t let you render the sign in button to begin with \u0026ndash; unless you override the WebView\u0026rsquo;s user agent string. After that, you\u0026rsquo;re still screwed if you rely on the default sign in workflow as this would open a popup window from which the user then can\u0026rsquo;t navigate back into your app.\u003c/p\u003e","title":"Hybrid apps \u0026 social logins: tread carefully."}]