<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daonware</title>
    <description>The latest articles on DEV Community by Daonware (@daonwareit).</description>
    <link>https://dev.to/daonwareit</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4002395%2Fb3f6d671-0f56-403e-8a87-58a3d10f0e31.png</url>
      <title>DEV Community: Daonware</title>
      <link>https://dev.to/daonwareit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/daonwareit"/>
    <language>en</language>
    <item>
      <title>FairTrack — Building an Offline-First Nutrition Tracker for Android (No Account, No Cloud, No Ads)</title>
      <dc:creator>Daonware</dc:creator>
      <pubDate>Fri, 10 Jul 2026 12:46:21 +0000</pubDate>
      <link>https://dev.to/daonwareit/fairtrack-building-an-offline-first-nutrition-tracker-for-android-no-account-no-cloud-no-ads-565</link>
      <guid>https://dev.to/daonwareit/fairtrack-building-an-offline-first-nutrition-tracker-for-android-no-account-no-cloud-no-ads-565</guid>
      <description>&lt;h2&gt;
  
  
  Why I built another calorie tracker
&lt;/h2&gt;

&lt;p&gt;Every mainstream calorie tracker follows roughly the same playbook: create an account before you're allowed to log a banana, sync your eating habits to a server, and monetize that data one way or another — subscriptions, ads, or both. I wanted something different: an app that opens straight into today's diary, stores everything locally, and sends exactly one thing over the network — a barcode I chose to scan.&lt;/p&gt;

&lt;p&gt;That app is &lt;strong&gt;FairTrack&lt;/strong&gt;, and version 1.0.1 just shipped as the first public release. It's built with Kotlin and Jetpack Compose, and it's source-available on GitHub. In this post I want to walk through some of the more interesting engineering decisions: the offline-first data model, why there's no charting library, how the Room migrations are structured, and the privacy trade-offs that shaped the architecture.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/daonware-it/FairTrack" rel="noopener noreferrer"&gt;github.com/daonware-it/FairTrack&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, briefly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Kotlin 2.3.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Jetpack Compose (Material 3), Glance for widgets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architecture&lt;/td&gt;
&lt;td&gt;MVVM, unidirectional state via &lt;code&gt;StateFlow&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DI&lt;/td&gt;
&lt;td&gt;Hilt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;Room (versioned migrations), DataStore for preferences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Networking&lt;/td&gt;
&lt;td&gt;Retrofit, OkHttp, kotlinx.serialization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background work&lt;/td&gt;
&lt;td&gt;WorkManager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images&lt;/td&gt;
&lt;td&gt;Coil&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;minSdk&lt;/code&gt; is 24 (Android 7.0), with core library desugaring so &lt;code&gt;java.time&lt;/code&gt; is usable across all supported versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline-first is a data-modeling problem, not a UI problem
&lt;/h2&gt;

&lt;p&gt;"Offline-first" is often treated as a caching layer bolted onto an otherwise online app. I wanted it to be the default state, not a fallback. Concretely that meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A curated offline catalogue of 116 fruits and vegetables&lt;/strong&gt;, bundled with the app, so the single most common logging action — "I ate an apple" — never touches the network at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Micronutrient reference data&lt;/strong&gt; derived from the German &lt;em&gt;Bundeslebensmittelschlüssel&lt;/em&gt; (BLS 4.0, Max Rubner-Institut, CC BY 4.0), also bundled locally, covering 14 vitamins and minerals against EU nutrient reference values (Regulation 1169/2011).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Barcode scanning as the only network-dependent path.&lt;/strong&gt; CameraX + ML Kit handle the scan itself on-device; only the resolved barcode is sent to the &lt;a href="https://openfoodfacts.org" rel="noopener noreferrer"&gt;Open Food Facts&lt;/a&gt; API (&lt;code&gt;ODbL&lt;/code&gt; license) to fetch product data. No analytics SDK ships in the dependency graph — that's directly checkable in the build files.
The practical effect: you can install FairTrack in airplane mode, log a custom meal, hit your macro goals, and never notice the network doesn't exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Room migrations: destructive fallback is a trap
&lt;/h2&gt;

&lt;p&gt;Room's &lt;code&gt;fallbackToDestructiveMigration()&lt;/code&gt; is tempting during early development — schema changes stop being annoying. But for an app whose entire value proposition is "your data lives only on your device," a silent wipe on a missed migration is unacceptable. There's no server copy to restore from.&lt;/p&gt;

&lt;p&gt;So the migration policy is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every schema change ships an &lt;strong&gt;explicit, versioned &lt;code&gt;Migration&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;data/Migrations.kt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The Room schema is exported to &lt;code&gt;app/schemas/&lt;/code&gt; and checked in, so historical schemas are diffable in review.&lt;/li&gt;
&lt;li&gt;Destructive fallback is enabled &lt;strong&gt;only for downgrades&lt;/strong&gt;, not upgrades — a missing forward migration fails loudly (crash, visible bug report) instead of quietly deleting a user's weight history.
A minimal example of the pattern — adding a column instead of letting Room recreate the table:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;MIGRATION_4_5&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="err"&gt;: &lt;/span&gt;&lt;span class="nc"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;migrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SupportSQLiteDatabase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execSQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"ALTER TABLE diary_entry ADD COLUMN portion_note TEXT DEFAULT NULL"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is more upfront work per schema change, but it matches the app's actual promise: there's no cloud backup to fall back on, so local data integrity has to be treated as load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why there's no charting library
&lt;/h2&gt;

&lt;p&gt;Statistics — weekly calorie bars, a macro donut, logging streaks — are drawn directly on a Compose &lt;code&gt;Canvas&lt;/code&gt;. No Vico, no MPAndroidChart wrapper, nothing pulled from Maven for this.&lt;/p&gt;

&lt;p&gt;The reasoning was mostly pragmatic rather than ideological: the chart set is small and fixed (bars, a donut, a streak grid), the styling needs to match the app's Material You theming exactly rather than approximate it through a library's theming API, and a raw &lt;code&gt;Canvas&lt;/code&gt; implementation is a few hundred lines total versus a dependency that would otherwise dominate the APK's chart-related surface area. For a small, fixed set of visualizations, hand-rolling them ended up being both smaller and easier to theme consistently than adapting a general-purpose library.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloud backup is disabled on purpose
&lt;/h2&gt;

&lt;p&gt;This one surprises people: Android's automatic cloud backup (&lt;code&gt;android:allowBackup&lt;/code&gt; / auto backup to Google Drive) is explicitly turned &lt;strong&gt;off&lt;/strong&gt;. The default Android behavior would otherwise back up the app's local database — including diary entries, weight history, and body measurements — to the user's Google Drive without any additional code from me.&lt;/p&gt;

&lt;p&gt;That's exactly the kind of silent cloud sync FairTrack is meant to avoid, so it's disabled deliberately, and users get an explicit, user-initiated alternative instead: &lt;strong&gt;export the whole diary to a JSON file&lt;/strong&gt; and restore it on another device manually. Opt-in, visible, and fully under the user's control — the opposite of a background sync job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goal calculation: Mifflin–St Jeor, with guardrails
&lt;/h2&gt;

&lt;p&gt;Calorie and macro targets come from the Mifflin–St Jeor BMR equation multiplied by an activity factor, with manual overrides available for anyone who wants to set their own numbers directly. A few things layered on top of the raw formula:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Protein and fat scale with goal and activity level&lt;/strong&gt;; carbohydrates take whatever's left in the calorie budget, rather than all three macros being independently user-set percentages that can silently sum to something nonsensical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BMI feedback uses an age-dependent healthy range&lt;/strong&gt; rather than a single fixed cutoff.&lt;/li&gt;
&lt;li&gt;There's a &lt;strong&gt;guard against underweight targets&lt;/strong&gt; — the app won't quietly let a goal calculation land somewhere unsafe without surfacing that.
None of this is a substitute for medical advice, and the app says so explicitly (nutrition data accuracy depends on crowd-sourced sources, and micronutrient coverage in particular is uneven).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  CI/CD and keeping the signing key out of the repo
&lt;/h2&gt;

&lt;p&gt;A few pipeline details that might be useful if you're setting up something similar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ci.yml&lt;/code&gt; compiles the debug variant on every push and PR.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;codeql.yml&lt;/code&gt; runs CodeQL static analysis on the Kotlin sources on a schedule and on PRs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;benchmark.yml&lt;/code&gt; runs Macrobenchmark-based cold/warm startup timings weekly on an emulator — the trend matters more than any single absolute number.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;release.yml&lt;/code&gt; builds and signs the APK for GitHub Releases on tag push.&lt;/li&gt;
&lt;li&gt;Third-party GitHub Actions are &lt;strong&gt;pinned to commit SHAs&lt;/strong&gt;, not floating tags, and every workflow starts from &lt;code&gt;permissions: {}&lt;/code&gt; before opting into what it actually needs.
The signed Play Store bundle is built by a &lt;strong&gt;separate Azure DevOps pipeline&lt;/strong&gt;, so the Play upload key never has to exist as a secret inside the GitHub repo at all — a clean separation between "public CI for a source-available repo" and "the credential that can push to production."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One consequence of shipping both a sideloaded APK and a Play Store build: they're signed with different keys, and Android refuses to let one signature replace another. A sideloaded install and a Play install can't update each other — switching sources means exporting your data, uninstalling, and restoring on the other build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;1.0.1 is the first public release, so the immediate focus is stability and closing gaps in micronutrient coverage.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The license is source-available, not OSI open source: you may read, build, modify and run FairTrack for private, non-commercial use, and contribute back. Redistribution — including publishing it to an app store — requires visible attribution to the project. Commercial use needs written permission. Full terms are in &lt;a href="https://github.com/daonware-it/FairTrack/blob/main/LICENSE" rel="noopener noreferrer"&gt;&lt;code&gt;LICENSE&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/daonware-it/FairTrack" rel="noopener noreferrer"&gt;github.com/daonware-it/FairTrack&lt;/a&gt;&lt;br&gt;
Releases: &lt;a href="https://github.com/daonware-it/FairTrack/releases/latest" rel="noopener noreferrer"&gt;Latest release&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to go deeper on any of the pieces above — the migration strategy, the &lt;code&gt;Canvas&lt;/code&gt; chart implementation, or the &lt;code&gt;WorkManager&lt;/code&gt; setup behind the fasting timer's persistent notification — if there's interest in the comments.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>jetpackcompose</category>
      <category>privacy</category>
    </item>
    <item>
      <title>I Built a Browser Extension to Bring Back the Like/Dislike Badge in YouTube Studio</title>
      <dc:creator>Daonware</dc:creator>
      <pubDate>Thu, 25 Jun 2026 12:55:37 +0000</pubDate>
      <link>https://dev.to/daonwareit/i-built-a-browser-extension-to-bring-back-the-likedislike-badge-in-youtube-studio-cn6</link>
      <guid>https://dev.to/daonwareit/i-built-a-browser-extension-to-bring-back-the-likedislike-badge-in-youtube-studio-cn6</guid>
      <description>&lt;p&gt;YouTube Studio used to show a small like/dislike badge right next to each video in the content tab. At some point it just... disappeared. No announcement, no reason — it was simply gone.&lt;/p&gt;

&lt;p&gt;As someone who likes to keep an eye on how videos are performing at a glance, this was annoying. Opening each video individually just to check the ratio felt like a massive step backwards.&lt;/p&gt;

&lt;p&gt;So I did what developers do: I built a fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;If you manage a YouTube channel, you know the content tab is your command center. At a glance you can see views, comments, watch time — but &lt;strong&gt;no like/dislike ratio&lt;/strong&gt;. YouTube quietly stopped showing it.&lt;/p&gt;

&lt;p&gt;The numbers aren't gone though. YouTube Studio still loads them internally when you open the page. They're just... not displayed anymore.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Idea: Intercept, Don't Request
&lt;/h2&gt;

&lt;p&gt;My first instinct was to use the YouTube Data API. But that comes with OAuth flows, quota limits, and a setup process that's anything but seamless.&lt;/p&gt;

&lt;p&gt;Then I noticed something while digging through DevTools: YouTube Studio's own frontend was already fetching the like counts via internal &lt;code&gt;youtubei&lt;/code&gt; endpoints — it just wasn't rendering them.&lt;/p&gt;

&lt;p&gt;That changed everything. Instead of making new API calls, I could simply &lt;strong&gt;listen to what the page was already loading&lt;/strong&gt; and inject the badge myself.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;The extension has two core scripts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/inject.js&lt;/code&gt;&lt;/strong&gt; — Intercepts the internal network responses from &lt;code&gt;studio.youtube.com&lt;/code&gt; by wrapping the native &lt;code&gt;XMLHttpRequest&lt;/code&gt;. When a response contains video metadata (detected via known field names like &lt;code&gt;likeCount&lt;/code&gt; and &lt;code&gt;videoId&lt;/code&gt;), it extracts the data and broadcasts it via a custom DOM event.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified version of the interception logic&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originalOpen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;XMLHttpRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;XMLHttpRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;load&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responseText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;videos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractVideos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// walk the response tree&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ytsr-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;videos&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;originalOpen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/content.js&lt;/code&gt;&lt;/strong&gt; — Listens for those custom events and injects the badge into the correct DOM row for each video. It uses a &lt;code&gt;MutationObserver&lt;/code&gt; to handle YouTube's dynamic rendering.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ytsr-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;videos&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findRowByVideoId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;injectBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;likes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result: a &lt;code&gt;👍 92.4%&lt;/code&gt; badge appears directly in the content tab, with exact numbers on hover.&lt;/p&gt;




&lt;h2&gt;
  
  
  Privacy First — By Design
&lt;/h2&gt;

&lt;p&gt;This was a non-negotiable constraint from the start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Runs &lt;strong&gt;only&lt;/strong&gt; on &lt;code&gt;studio.youtube.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;✅ Makes &lt;strong&gt;zero additional network requests&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;✅ Sends &lt;strong&gt;no data&lt;/strong&gt; to any third-party server&lt;/li&gt;
&lt;li&gt;✅ Only reads data from &lt;strong&gt;your own logged-in channel&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;✅ Nothing is stored beyond your local browser session&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The extension is essentially a read-only lens on data that was already traveling through your browser.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tricky Parts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  YouTube Changes Without Warning
&lt;/h3&gt;

&lt;p&gt;Internal endpoints aren't documented. YouTube can rename field keys at any time — and they do. To handle this, I built a &lt;strong&gt;debug mode&lt;/strong&gt; directly into the popup. When enabled, it logs every intercepted payload to the console under a &lt;code&gt;[YTSR]&lt;/code&gt; prefix, making it easy to spot which field names changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dislikes Are Not There (Yet)
&lt;/h3&gt;

&lt;p&gt;Here's the honest limitation: &lt;strong&gt;YouTube does not include dislike counts in the Studio content endpoint.&lt;/strong&gt; The extension currently shows like counts only. The "ratio" in the name reflects the roadmap — once I integrate the YouTube Analytics API (which does expose dislikes), the badge will become a true ratio. That requires OAuth per user though, so the UX tradeoff needs careful thought.&lt;/p&gt;

&lt;h3&gt;
  
  
  MV3 Restrictions
&lt;/h3&gt;

&lt;p&gt;Manifest V3 removed &lt;code&gt;webRequest&lt;/code&gt; blocking, which is commonly used for response interception. The workaround here is wrapping &lt;code&gt;XMLHttpRequest&lt;/code&gt; from an injected script — which works, but requires careful coordination between the injected world and the content script world via DOM events.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Extension API&lt;/td&gt;
&lt;td&gt;Manifest V3 (Chrome, Edge, Brave, Firefox)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Core Logic&lt;/td&gt;
&lt;td&gt;Vanilla JavaScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;None — ships as unpacked source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev Environment&lt;/td&gt;
&lt;td&gt;VS Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version Control&lt;/td&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Deliberately no framework, no bundler, no dependencies. The whole extension is a handful of files.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;Building a browser extension that intercepts live page data taught me a lot about how modern web apps load and render dynamic content. The gap between "the data exists" and "the data is visible" is often just a missing render step — and that's a surprisingly powerful place to work.&lt;/p&gt;

&lt;p&gt;It also reinforced something I keep coming back to: &lt;strong&gt;the best tools solve your own problem first.&lt;/strong&gt; I built this because I genuinely missed that badge. Every decision — no external requests, no OAuth, debug mode built-in — came from thinking about what I'd want as a user.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;The extension is open source and ready to install locally:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone the repo: &lt;code&gt;git clone https://github.com/daonware-it/yt-studio-ratio&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Open &lt;code&gt;chrome://extensions&lt;/code&gt; and enable Developer Mode&lt;/li&gt;
&lt;li&gt;Click "Load unpacked" and select the folder&lt;/li&gt;
&lt;li&gt;Head to YouTube Studio → Content tab&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/daonware-it/yt-studio-ratio" rel="noopener noreferrer"&gt;GitHub — daonware-it/yt-studio-ratio&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
👉 &lt;strong&gt;&lt;a href="https://chromewebstore.google.com/detail/yt-studio-likedislike-rat/glojdhijcgeieoikihijfmdjfilhemcm?authuser=0&amp;amp;hl=de" rel="noopener noreferrer"&gt;Google Chrome Extension — YT Studio Like/Dislike Ratio&lt;br&gt;
&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the badge doesn't appear, flip on Debug Mode in the popup and open the console — you'll see exactly what's happening. And if you run into issues or have ideas, open an issue or drop a comment below. I'd love to hear how it works for you.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>html</category>
    </item>
  </channel>
</rss>
