<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Ivan Chernoknizhnikov — Blog</title>
    <link>https://treamz.me/blog/</link>
    <description>Notes from building Flutter and iOS apps: rendering, animation, performance, and shipping my own apps to the App Store.</description>
    <language>en</language>
    <lastBuildDate>Wed, 11 Nov 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://treamz.me/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Aquarium water test log apps for iPhone: 8 compared</title>
      <link>https://treamz.me/blog/aquarium-log-apps-compared</link>
      <guid isPermaLink="true">https://treamz.me/blog/aquarium-log-apps-compared</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <description>Eight iPhone apps for logging aquarium water tests compared: freshwater or reef, test strip scanning, species database, account, and what each costs.</description>
      <category>Aquarium</category>
      <category>Fishkeeping</category>
      <category>Water Testing</category>
      <category>iOS</category>
      <category>App Comparison</category>
      <content:encoded><![CDATA[<p>If you want an iPhone app to log your aquarium water tests, the right one
depends on what else you want from it. For a plain, free log of every tank:
Aquarium - Log Tracker, which I built, so weigh that. For a reef with a
controller: AquaticLog. For a species library with compatibility checks:
Aquarimate or Pocket Marine. To read test strips with the camera: AquaLens. To
know where a new tank is in its cycle: Nitrify. Each section below says where
an app is the better pick, mine included.</p>
<p>The short version:</p>
<ul>
<li><strong>Free, every tank, no account:</strong> Aquarium - Log Tracker.</li>
<li><strong>Reef with an Apex, Hydros or Seneye:</strong> AquaticLog.</li>
<li><strong>Species library and compatibility, pay once:</strong> Aquarimate ($9.99) or, for
marine, Pocket Marine ($3.99).</li>
<li><strong>Scan test strips, identify fish from a photo:</strong> AquaLens.</li>
<li><strong>Cycling a new freshwater tank:</strong> Nitrify.</li>
<li><strong>Fish plus reptiles or other pets:</strong> Fishi.</li>
<li><strong>Reef with an AI assistant:</strong> Reef Buddy.</li>
</ul>
<h2 id="how-the-eight-apps-compare"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#how-the-eight-apps-compare">How the eight apps compare</a></h2>
<div class="table"><table>
<thead>
<tr>
<th>App</th>
<th>Tanks</th>
<th>Camera reads tests</th>
<th>Species library</th>
<th>Account needed</th>
<th>Price (US)</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://apps.apple.com/us/app/aquarium-log-tracker/id6804384133">Aquarium - Log Tracker</a></td>
<td>Fresh and salt, unlimited</td>
<td>No</td>
<td>No</td>
<td>No (optional sync)</td>
<td>Free, no in-app purchases</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/aquaticlog/id583895470">AquaticLog</a></td>
<td>Fresh and reef, several</td>
<td>No; reads controllers</td>
<td>Livestock records</td>
<td>Yes</td>
<td>Free download</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/aquarimate/id587215055">Aquarimate</a></td>
<td>Fresh and salt, unlimited</td>
<td>No</td>
<td>Yes, with compatibility</td>
<td>No (optional cloud)</td>
<td>$9.99 once</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/pocket-marine/id999538938">Pocket Marine</a></td>
<td>Saltwater</td>
<td>No</td>
<td>800+ marine species</td>
<td>No</td>
<td>$3.99 once</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/fishi-aquarium-manager/id6450653339">Fishi</a></td>
<td>Fish and other pets</td>
<td>No</td>
<td>Hundreds of species</td>
<td>Not stated</td>
<td>Free download; subscription</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/nitrify-aquarium-water-test/id6787839482">Nitrify</a></td>
<td>Freshwater; 1 tank free</td>
<td>No</td>
<td>No</td>
<td>No (iCloud)</td>
<td>Free; Pro is a one-time unlock</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/aqualens-aquarium-log-tracker/id6760222697">AquaLens</a></td>
<td>Fresh and salt</td>
<td>Yes, strips and liquid kits</td>
<td>Photo ID</td>
<td>Not stated</td>
<td>Free to start; Pro</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/reef-buddy-reef-tank-tracker/id1593580822">Reef Buddy</a></td>
<td>Reef</td>
<td>No</td>
<td>No</td>
<td>Syncs across devices</td>
<td>Free; premium AI</td>
</tr>
</tbody></table></div>
<p>Everything here is from each app&#39;s App Store listing in November 2026. &quot;Not
stated&quot; means the listing doesn&#39;t say.</p>
<h2 id="what-makes-a-water-test-log-worth-keeping"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#what-makes-a-water-test-log-worth-keeping">What makes a water test log worth keeping</a></h2>
<p>A single test says where the tank is today. A log says which way it&#39;s going:
nitrate climbing between water changes, alkalinity swinging, a heater that
sticks. Three details decide whether an app&#39;s log is trustworthy.</p>
<ol>
<li><strong>A blank stays blank.</strong> &quot;I didn&#39;t test nitrite&quot; and &quot;nitrite was zero&quot; are
different facts. An app that stores the first as the second draws trends
that never happened.</li>
<li><strong>Units follow your kit.</strong> Nitrate as NO3 or NO3-N, alkalinity as dKH,
meq/L or ppm. Mixing them makes a chart lie by a factor of four or more.</li>
<li><strong>Free ammonia, not just total.</strong> Most kits read total ammonia, but the
toxic part is un-ionised ammonia (NH3), and its share grows with pH and
temperature: <a href="https://www.epa.gov/caddis/ammonia">roughly ten times larger at pH 8 than at pH 7</a>.</li>
</ol>
<p>Then there&#39;s what you want beyond the log: reminders, a species library,
camera scanning, controller data, or help while a new tank cycles.</p>
<h2 id="aquarium-log-tracker-a-free-log-for-every-tank"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#aquarium-log-tracker-a-free-log-for-every-tank">Aquarium - Log Tracker: a free log for every tank</a></h2>
<p><a href="https://apps.apple.com/us/app/aquarium-log-tracker/id6804384133">Aquarium - Log Tracker</a>
puts every parameter a tank tracks on one page, with your last values in grey,
and a timer per test for the wait between reagent and reading. A field you
leave blank is never stored as zero. After you save, it works out free ammonia
from the pH and temperature in the same session and flags readings outside the
tank&#39;s target band.</p>
<p>All parameters share one time axis on the charts, so a temperature spike and a
nitrate spike line up, and livestock losses are marked across all of them.
There are reminders for water changes, testing, filters and dosing across
every tank, a livestock and equipment list, and a journal. While a tank
cycles, it shows which stage the readings point to and calls it cycled only
after two sessions in a row with ammonia and nitrite clear and nitrate
present.</p>
<p>It&#39;s free with no in-app purchases, subscription or ads, for as many tanks as
you keep. Signed out, everything stays on the phone; sign in only if you want
it on another device. Export to CSV and JSON is free.</p>
<p><strong>Limits:</strong> iPhone only (iOS 13 or later), English only, every value typed by
hand (no strip scanning, no controllers), no species database, and no import
of old logs beyond backdating readings one at a time.</p>
<figure><img src="https://treamz.me/blog/aquarium-water-test-log-app/quick-log.jpg" alt="Aquarium&#39;s log screen: one page of fields, previous values in grey, a timer beside each" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Aquarium - Log Tracker: blank fields are not recorded as zero</figcaption></figure>
<h2 id="aquaticlog-account-based-with-controller-data"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#aquaticlog-account-based-with-controller-data">AquaticLog: account-based, with controller data</a></h2>
<p><a href="https://apps.apple.com/us/app/aquaticlog/id583895470">AquaticLog</a> has been
around since 2013 and rates 4.2 from 409 ratings. Your log lives in an
AquaticLog account, so it&#39;s on your iPhone, iPad and the web at
aquaticlog.com. It logs the usual parameters and can pull hourly readings from
a Neptune Apex, Hydros, AquaWiz or Seneye, including Seneye&#39;s NH3 and NH4. It
keeps detailed livestock records with reef-safety and temperament, schedules
retests for out-of-range results, and has Home Screen widgets.</p>
<p><strong>Pick it if</strong> you run a reef or a high-tech tank with a controller, or want
your log in a browser too. It needs iOS 18.1 or later and an account.</p>
<h2 id="aquarimate-species-library-and-compatibility"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#aquarimate-species-library-and-compatibility">Aquarimate: species library and compatibility</a></h2>
<p><a href="https://apps.apple.com/us/app/aquarimate/id587215055">Aquarimate</a> costs
$9.99 once and rates 4.4 from 626 ratings. It manages unlimited fresh and
saltwater tanks, lets you add or remove parameters per tank, has unit
converters and dosing calculators, a test timer, and charts that plot several
parameters with your maintenance on one graph. Its Aquaribase library covers
fish, coral, invertebrates and plants with compatibility advice, and it tracks
expenses and equipment. It&#39;s available in 13 languages; the last update was in
June 2025.</p>
<p><strong>Pick it if</strong> you want a stocking library and compatibility checks in the
same app as your log, and prefer to pay once.</p>
<h2 id="pocket-marine-for-saltwater-keepers"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#pocket-marine-for-saltwater-keepers">Pocket Marine: for saltwater keepers</a></h2>
<p><a href="https://apps.apple.com/us/app/pocket-marine/id999538938">Pocket Marine</a>
costs $3.99 once and has the best rating here, 4.8 from 597 ratings. It&#39;s
built for marine tanks: an encyclopaedia of over 800 marine fish,
invertebrates and corals with compatibility notes, a testing journal for more
than 30 parameters with trend graphs, dosing and water change logs, an
inhabitants diary and reminders. It was last updated in April 2024.</p>
<p><strong>Pick it if</strong> you keep a saltwater tank and want a species reference
alongside the log for a small one-off price.</p>
<h2 id="fishi-fish-and-every-other-pet"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#fishi-fish-and-every-other-pet">Fishi: fish and every other pet</a></h2>
<p><a href="https://apps.apple.com/us/app/fishi-aquarium-manager/id6450653339">Fishi</a>
rates 4.6 from 596 ratings and covers terrariums, aviaries and enclosures as
well as aquariums. It monitors water parameters, gives insights on cycling
progress, has feeding and maintenance reminders, and a library of hundreds of
fish, reptile, insect, bird and small animal species. It runs on a monthly,
quarterly or yearly subscription.</p>
<p><strong>Pick it if</strong> you keep reptiles or other animals as well as fish and want
them in one app.</p>
<h2 id="nitrify-for-a-tank-thats-still-cycling"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#nitrify-for-a-tank-thats-still-cycling">Nitrify: for a tank that&#39;s still cycling</a></h2>
<p><a href="https://apps.apple.com/us/app/nitrify-aquarium-water-test/id6787839482">Nitrify</a>
is for freshwater keepers. You log ammonia, nitrite and nitrate, and it puts
every test in a cycle stage (waiting on ammonia, the nitrite spike, finishing,
cycled) with a Safe, Watch or Act call on each reading. The free version
covers logging, the cycle stage, the safety verdict, one tank and iCloud sync;
a one-time Pro unlock adds multiple tanks, reminders, custom schedules and an
estimate of when the cycle will finish. Nothing is sent to a server. It needs iOS 26.2 or later.</p>
<p><strong>Pick it if</strong> you&#39;re cycling your first freshwater tank and want a plain
verdict on every reading.</p>
<h2 id="aqualens-camera-scanning-and-photo-id"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#aqualens-camera-scanning-and-photo-id">AquaLens: camera scanning and photo ID</a></h2>
<p><a href="https://apps.apple.com/us/app/aqualens-aquarium-log-tracker/id6760222697">AquaLens</a>
scans test strips and liquid test kit results with the camera, identifies
fish, plants, corals, algae and snails from a photo, and gives AI guidance
based on your tank history. It also keeps the usual log, chemistry trends and
maintenance timeline. It&#39;s free to start, with a Pro tier for advanced
analysis, and rates 4.6 from 32 ratings.</p>
<p><strong>Pick it if</strong> typing readings is what stops you logging, or you want to
identify what&#39;s in your tank.</p>
<h2 id="reef-buddy-reef-tracking-with-an-ai-assistant"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#reef-buddy-reef-tracking-with-an-ai-assistant">Reef Buddy: reef tracking with an AI assistant</a></h2>
<p><a href="https://apps.apple.com/us/app/reef-buddy-reef-tank-tracker/id1593580822">Reef Buddy</a>
logs reef parameters (KH, salinity, pH, calcium, magnesium, nitrate,
phosphate, silicate and more) with trends and automated insights, and syncs
between iPhone and iPad. A premium tier adds Shrimpy, an assistant that
analyses your data and tank photos and answers questions. It needs iOS 26 or
later and rates 3.9 from 20 ratings.</p>
<p><strong>Pick it if</strong> you keep a reef and want to ask questions about your numbers.</p>
<h2 id="which-aquarium-log-app-should-you-use"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#which-aquarium-log-app-should-you-use">Which aquarium log app should you use?</a></h2>
<ul>
<li><strong>You want a free, honest log for one or many tanks:</strong> Aquarium - Log
Tracker.</li>
<li><strong>You have a controller or want a web log:</strong> AquaticLog.</li>
<li><strong>You want a species library and pay once:</strong> Aquarimate, or Pocket Marine
for saltwater.</li>
<li><strong>You won&#39;t type readings:</strong> AquaLens.</li>
<li><strong>Your first freshwater tank is cycling:</strong> Nitrify.</li>
<li><strong>You keep more than fish:</strong> Fishi.</li>
</ul>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#faq">FAQ</a></h2>
<h3 id="what-is-the-best-app-to-log-aquarium-water-tests"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#what-is-the-best-app-to-log-aquarium-water-tests">What is the best app to log aquarium water tests?</a></h3>
<p>It depends on the tank. Aquarium - Log Tracker is free with no in-app purchases
for any number of fresh or saltwater tanks; AquaticLog adds controller data and
a web log; Aquarimate and Pocket Marine add species libraries for a one-off
price; AquaLens reads test strips with the camera.</p>
<h3 id="is-there-a-free-aquarium-log-app-with-no-subscription"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#is-there-a-free-aquarium-log-app-with-no-subscription">Is there a free aquarium log app with no subscription?</a></h3>
<p>Yes. Aquarium - Log Tracker is free with no in-app purchases, subscription or
ads, for unlimited tanks, and exports to CSV and JSON for free. Nitrify&#39;s free
version covers one freshwater tank, with a one-time Pro unlock for more.</p>
<h3 id="which-aquarium-app-can-read-test-strips-with-the-camera"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#which-aquarium-app-can-read-test-strips-with-the-camera">Which aquarium app can read test strips with the camera?</a></h3>
<p>AquaLens scans test strips and liquid test kit results with the iPhone camera
and keeps the readings in its log. Most other aquarium log apps, including
Aquarium - Log Tracker, need readings typed in.</p>
<h3 id="which-app-tracks-the-nitrogen-cycle-in-a-new-tank"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#which-app-tracks-the-nitrogen-cycle-in-a-new-tank">Which app tracks the nitrogen cycle in a new tank?</a></h3>
<p>Nitrify puts every freshwater test into a cycle stage with a Safe, Watch or Act
verdict. Aquarium - Log Tracker shows which stage the readings point to and
calls a tank cycled after two sessions in a row with ammonia and nitrite clear
and nitrate present.</p>
<h3 id="is-there-a-reef-tank-app-that-connects-to-a-neptune-apex"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#is-there-a-reef-tank-app-that-connects-to-a-neptune-apex">Is there a reef tank app that connects to a Neptune Apex?</a></h3>
<p>AquaticLog can pull hourly readings from a Neptune Apex, Hydros, AquaWiz or
Seneye into its log, alongside manual tests.</p>
<h3 id="can-i-track-several-aquariums-in-one-app"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#can-i-track-several-aquariums-in-one-app">Can I track several aquariums in one app?</a></h3>
<p>Yes. Aquarium - Log Tracker and Aquarimate handle unlimited tanks; Aquarium -
Log Tracker charges nothing for extra ones, and Aquarimate is a one-off $9.99.
Nitrify and others limit the free version to one tank.</p>
<h3 id="is-aquarium-log-tracker-free"><a class="anchor" href="https://treamz.me/blog/aquarium-log-apps-compared#is-aquarium-log-tracker-free">Is Aquarium - Log Tracker free?</a></h3>
<p>Yes. It&#39;s free to download, with no in-app purchases, subscription or ads, and
no account is needed. It&#39;s iPhone only and needs iOS 13 or later.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Free parking in Madrid, Barcelona and Valencia: 7 apps compared</title>
      <link>https://treamz.me/blog/free-parking-apps-spain</link>
      <guid isPermaLink="true">https://treamz.me/blog/free-parking-apps-spain</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <description>Most Spanish parking apps help you pay, not park free. Seven apps compared for Madrid, Barcelona and Valencia, and which one maps free street parking.</description>
      <category>Parking</category>
      <category>Spain</category>
      <category>Madrid</category>
      <category>Barcelona</category>
      <category>Valencia</category>
      <category>Travel</category>
      <content:encoded><![CDATA[<p>If you want to park for free in Madrid, Barcelona or Valencia, most parking
apps won&#39;t help: ElParking, Telpark, EasyPark and SMOU are for paying the
blue zone, and Parclick is for booking garages. The one app on this list that
maps free street parking kerb by kerb in all three cities is Easy Find
Parking, which I built, so read the comparison with that in mind. I&#39;ve tried
to say plainly where each of the others is the better choice.</p>
<p>The short version:</p>
<ul>
<li><strong>To find free street parking:</strong> Easy Find Parking (iPhone and iPad, free).</li>
<li><strong>To pay the blue zone in Barcelona:</strong> SMOU, the city&#39;s own app.</li>
<li><strong>To pay the blue zone in Madrid or Valencia:</strong> ElParking, Telpark or
EasyPark, all accepted by the city.</li>
<li><strong>To book a garage in advance:</strong> Parclick or ElParking.</li>
</ul>
<h2 id="how-the-seven-apps-compare"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#how-the-seven-apps-compare">How the seven apps compare</a></h2>
<div class="table"><table>
<thead>
<tr>
<th>App</th>
<th>What it&#39;s for</th>
<th>Free street parking map</th>
<th>Madrid</th>
<th>Barcelona</th>
<th>Valencia</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://apps.apple.com/us/app/easy-find-parking-europe/id6502238318">Easy Find Parking</a></td>
<td>Finding free kerbs and car parks</td>
<td>Yes, kerb by kerb</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>Free, no ads; iPhone and iPad only</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/app/id1439898721">SMOU</a></td>
<td>Paying Barcelona&#39;s blue and green zones</td>
<td>No</td>
<td>No</td>
<td>Yes</td>
<td>No</td>
<td>Free app; you pay the parking</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/es/app/elparking-app-para-conductores/id1120207710">ElParking</a></td>
<td>Paying regulated zones, booking garages</td>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>Free app; commission per payment</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/app/telpark/id658204860">Telpark</a></td>
<td>Paying regulated zones, Telpark car parks</td>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>Free app; commission per payment</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/-/id449594317">EasyPark</a></td>
<td>Paying regulated zones across Europe</td>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>15% service fee, or €2.99 a month</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/id1175900386">Parclick</a></td>
<td>Booking garages at a discount</td>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>Free app; booking fees</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/parking-madrid/id985106342">Parking Madrid</a></td>
<td>Public car parks in Madrid, run by EMT</td>
<td>No</td>
<td>Yes</td>
<td>No</td>
<td>No</td>
<td>Free; last updated 2021</td>
</tr>
</tbody></table></div>
<p>Coverage means the app does its main job in that city. I left out Parkopedia:
its App Store listing says the app stopped being supported on 1 July 2026 and
points to its website instead.</p>
<h2 id="why-is-free-parking-hard-to-find-in-spanish-cities"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#why-is-free-parking-hard-to-find-in-spanish-cities">Why is free parking hard to find in Spanish cities?</a></h2>
<p>Because the free streets are whatever the paid zones leave over, and each
city draws those zones its own way. Madrid charges in its SER zone, Barcelona
in its AREA blue and green zones, Valencia in its ORA blue zone. A street just
outside the zone is usually free, but the only thing on the street that tells
you so is a missing sign. The pay apps know where the zones are, because they
sell parking inside them, but none of them draws the streets outside.</p>
<p>Councils do publish the data. Valencia publishes its free spaces outright,
about 281,000 of them. Madrid publishes 87,000 kerb bands, and around 331,000
spaces lie outside its SER zone. Barcelona publishes the streets it charges
for. Turning those files into a map you can read from the driver&#39;s seat is
the gap Easy Find Parking fills.</p>
<h2 id="easy-find-parking-the-one-that-maps-free-kerbs"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#easy-find-parking-the-one-that-maps-free-kerbs">Easy Find Parking: the one that maps free kerbs</a></h2>
<p><a href="https://apps.apple.com/us/app/easy-find-parking-europe/id6502238318">Easy Find Parking</a>
draws every kerb in Madrid, Barcelona and Valencia (and Berlin and Kraków) as
a coloured line: solid green is free, dashed teal is likely free, orange is
charged right now. A kerb that charges only during posted hours turns green
outside them. Zoomed out, green circles count free parking by neighbourhood,
so you can pick an area before you set off.</p>
<p>&quot;Likely free&quot; is the important part. In Valencia the city says which spaces
are free, so they&#39;re green. In Madrid and Barcelona the app works out what
lies outside the paid zones and labels it &quot;likely free&quot;, because no council
document or sign confirms it. The guess is cautious: in Barcelona, main
roads and any street with a paid segment within 25 m are left out.</p>
<p>It also shows car parks with prices, height limits and opening hours, and
live space counts where the operator publishes them (mostly in Germany and
Switzerland, not yet in Spain).</p>
<p><strong>Limits:</strong> it doesn&#39;t take payments or bookings, it&#39;s iPhone and iPad only
(iOS 17 or later), English only, and there&#39;s no address search: you pan the
map. Lines show rules, not whether a space is empty right now.</p>
<figure><img src="https://treamz.me/blog/free-parking-app-europe/free-kerbs-valencia.jpg" alt="The map in València at street level, with free kerbs drawn as green lines on most streets" width="644" height="1400" loading="lazy" decoding="async"><figcaption>València: every green line is a kerb the city publishes as free</figcaption></figure>
<h2 id="smou-the-official-app-for-barcelonas-blue-and-green-zones"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#smou-the-official-app-for-barcelonas-blue-and-green-zones">SMOU: the official app for Barcelona&#39;s blue and green zones</a></h2>
<p><a href="https://apps.apple.com/app/id1439898721">SMOU</a> is run by Barcelona de
Serveis Municipals (B:SM), the city&#39;s own company. It pays AREA parking in
Barcelona and several towns around it, and you&#39;re charged by the minute, so
leaving early costs less. There&#39;s no ticket to put on the windscreen: the
permit is digital and enforcement checks it in real time. By B:SM&#39;s figures,
<a href="https://bsmsa.cat/en/press_releases/40-payments-regulated-parking-barcelona-and-metropolitan-area-are-now-made-using-smou-app">around 40% of regulated parking payments</a>
in Barcelona and its metropolitan area now go through the app.</p>
<p><strong>Pick it if</strong> you&#39;re going to park in Barcelona&#39;s blue or green zones anyway.
In green zones check the sign: some spaces are for residents only. SMOU shows
where you can pay, not where parking is free.</p>
<h2 id="elparking-blue-zones-plus-garage-bookings"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#elparking-blue-zones-plus-garage-bookings">ElParking: blue zones plus garage bookings</a></h2>
<p><a href="https://apps.apple.com/es/app/elparking-app-para-conductores/id1120207710">ElParking</a>
does two jobs: it works as a parking meter in regulated zones in Madrid,
Barcelona, Valencia and many other Spanish cities, and it books garages,
including airport parking, with free cancellation up to 24 hours before.
The app also handles the Via-T toll tag and MOT (ITV) appointments.</p>
<p>It&#39;s one of the apps Madrid accepts for SER payments, and Valencia&#39;s
council lists it for the ORA blue zone.</p>
<p><strong>Pick it if</strong> you want one Spanish app for both street payments and garage
bookings.</p>
<h2 id="telpark-blue-zones-and-telpark-car-parks"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#telpark-blue-zones-and-telpark-car-parks">Telpark: blue zones and Telpark car parks</a></h2>
<p><a href="https://apps.apple.com/app/telpark/id658204860">Telpark</a> pays regulated
parking in Madrid, Valencia and other cities in Spain and Portugal, and lets
you extend the time from the phone. At Empark car parks with plate
recognition, its Entrada Express opens the barrier by reading your number
plate, so there&#39;s no ticket. It also books airport parking and charges
electric cars at about 40 car parks. Valencia&#39;s council lists it, alongside
ElParking and EasyPark, as a way to pay the ORA.</p>
<p><strong>Pick it if</strong> you often use Telpark car parks or it&#39;s already on your phone.</p>
<h2 id="easypark-one-app-for-paid-zones-across-europe"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#easypark-one-app-for-paid-zones-across-europe">EasyPark: one app for paid zones across Europe</a></h2>
<p><a href="https://apps.apple.com/us/app/-/id449594317">EasyPark</a> pays street parking
in Madrid, Barcelona, Valencia and dozens of other Spanish cities, and in
many other European countries. In Spain, pay-per-use costs a
<a href="https://easypark.com/en-es/help/payment-pricing/easypark-service-fee">15% service fee</a>
on top of the parking, with a small minimum per session; the EasyPark GO plan
at €2.99 a month drops the fee. You pick an end time on a dial, and you can
stop early.</p>
<p><strong>Pick it if</strong> you drive in more than one country and want one app for every
blue zone.</p>
<h2 id="parclick-booking-a-garage-in-advance"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#parclick-booking-a-garage-in-advance">Parclick: booking a garage in advance</a></h2>
<p><a href="https://apps.apple.com/us/app/id1175900386">Parclick</a> books spaces in
garages and car parks in Spain, Italy, France and Portugal, often cheaper than
the gate price. You get a booking code to get in. It also pays meters in a
few zones and works with CarPlay. Check the total before you pay: one App
Store reviewer found the fees added about €8 to a €20 garage.</p>
<p><strong>Pick it if</strong> you need a guaranteed space, for example near a stadium or the
airport, and don&#39;t want to circle.</p>
<h2 id="parking-madrid-emts-public-car-parks"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#parking-madrid-emts-public-car-parks">Parking Madrid: EMT&#39;s public car parks</a></h2>
<p><a href="https://apps.apple.com/us/app/parking-madrid/id985106342">Parking Madrid</a>
is EMT Madrid&#39;s free app for the city&#39;s public car parks, with free spaces,
opening hours and directions. It hasn&#39;t been updated since March 2021, and
some recent reviews report errors when searching nearby. It doesn&#39;t cover
street parking.</p>
<p><strong>Pick it if</strong> you specifically want an EMT car park in Madrid, and check the
result against the operator&#39;s site.</p>
<h2 id="how-to-find-free-parking-without-any-app"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#how-to-find-free-parking-without-any-app">How to find free parking without any app</a></h2>
<ol>
<li><strong>Look up the paid zone first.</strong> Madrid&#39;s SER, Barcelona&#39;s AREA and
Valencia&#39;s ORA are all published by the councils. Outside them, street
parking is usually free.</li>
<li><strong>Read every sign on the block.</strong> Blue lines mean pay, green lines in
Madrid and Barcelona often mean residents first. White or unmarked kerbs
outside the zone are usually free.</li>
<li><strong>Check the hours.</strong> Blue zones charge only during the hours on the
sign, which differ by city and often by street, and some are free during
local holidays (Valencia&#39;s blue zone was free during Fallas in March 2026).</li>
<li><strong>Watch for exceptions:</strong> market days, roadworks and loading bays won&#39;t be
in any app.</li>
</ol>
<h2 id="which-app-should-you-use"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#which-app-should-you-use">Which app should you use?</a></h2>
<ul>
<li><strong>Visiting by car and want to avoid paying:</strong> Easy Find Parking to find
free or likely-free kerbs, and confirm with the sign.</li>
<li><strong>Parking in the centre of Barcelona:</strong> SMOU.</li>
<li><strong>Parking in the centre of Madrid or Valencia:</strong> ElParking, Telpark or
EasyPark; they do the same job, so pick by fees.</li>
<li><strong>Need a guaranteed space:</strong> Parclick or ElParking garage bookings.</li>
</ul>
<p>Many drivers end up with two: one to find where parking is free, and one to
pay when it isn&#39;t.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#faq">FAQ</a></h2>
<h3 id="is-there-an-app-that-shows-free-parking-in-madrid-barcelona-or-valencia"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#is-there-an-app-that-shows-free-parking-in-madrid-barcelona-or-valencia">Is there an app that shows free parking in Madrid, Barcelona or Valencia?</a></h3>
<p>Yes. Easy Find Parking, a free iPhone and iPad app, maps free street parking
kerb by kerb in Madrid, Barcelona and Valencia, as well as Berlin and Kraków.
Valencia publishes its free spaces directly; in Madrid and Barcelona the app
marks streets outside the paid zones as &quot;likely free&quot;. The popular Spanish
parking apps, such as ElParking, Telpark, EasyPark and SMOU, are for paying in
regulated zones and don&#39;t map free streets.</p>
<h3 id="which-app-do-i-use-to-pay-the-blue-zone-in-barcelona"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#which-app-do-i-use-to-pay-the-blue-zone-in-barcelona">Which app do I use to pay the blue zone in Barcelona?</a></h3>
<p>SMOU, the app of Barcelona de Serveis Municipals, the city&#39;s own company. It
pays the AREA blue and green zones in Barcelona and several nearby towns,
charges by the minute and replaces the paper ticket with a digital permit.</p>
<h3 id="which-apps-can-pay-ser-parking-in-madrid"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#which-apps-can-pay-ser-parking-in-madrid">Which apps can pay SER parking in Madrid?</a></h3>
<p>Madrid accepts several apps for its SER zone, including ElParking, Telpark,
EasyPark, ParkingLibre, Parclick and Bip&amp;Drive. They charge the official SER
rate plus their own commission, which differs between apps.</p>
<h3 id="which-apps-can-pay-the-ora-blue-zone-in-valencia"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#which-apps-can-pay-the-ora-blue-zone-in-valencia">Which apps can pay the ORA blue zone in Valencia?</a></h3>
<p>Valencia&#39;s council lists ElParking, Telpark and EasyPark for paying the ORA
blue zone, as well as the street meters.</p>
<h3 id="is-street-parking-free-outside-the-blue-zone-in-spain"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#is-street-parking-free-outside-the-blue-zone-in-spain">Is street parking free outside the blue zone in Spain?</a></h3>
<p>Usually, but not always. Outside the regulated zone, parking is generally
free unless a sign says otherwise, for example a residents-only, loading or
no-parking sign. Always check the signs on the block, since local exceptions
such as market days aren&#39;t published in any app.</p>
<h3 id="is-parkopedia-still-available"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#is-parkopedia-still-available">Is Parkopedia still available?</a></h3>
<p>Not as an app. Parkopedia&#39;s App Store listing says the app stopped being
supported on 1 July 2026 and directs users to its website for parking
information.</p>
<h3 id="does-easypark-charge-a-fee-in-spain"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#does-easypark-charge-a-fee-in-spain">Does EasyPark charge a fee in Spain?</a></h3>
<p>Yes. Pay-per-use parking with EasyPark in Spain carries a 15% service fee on
top of the parking cost, with a small minimum per session. The EasyPark GO
subscription at €2.99 a month removes the service fee.</p>
<h3 id="is-easy-find-parking-free"><a class="anchor" href="https://treamz.me/blog/free-parking-apps-spain#is-easy-find-parking-free">Is Easy Find Parking free?</a></h3>
<p>Yes. Easy Find Parking is free to download from the App Store and has no
subscription, in-app purchases or ads. It needs an iPhone or iPad with iOS 17
or later.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Pet health record apps for iPhone: 7 compared</title>
      <link>https://treamz.me/blog/pet-health-record-apps-compared</link>
      <guid isPermaLink="true">https://treamz.me/blog/pet-health-record-apps-compared</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <description>Seven iPhone apps for your pet&apos;s vaccinations, vet visits and documents compared: which work without your vet joining, store your own scans, and cost.</description>
      <category>Pet health records</category>
      <category>Vaccination tracker</category>
      <category>Vet records</category>
      <category>iOS</category>
      <category>App Comparison</category>
      <content:encoded><![CDATA[<p>If you want an app that holds your dog&#39;s or cat&#39;s vaccination records, vet
visits and certificates, the first question is whether your vet clinic uses
one. PetDesk, VitusVet, Digitail and GreatPetCare are strongest when your
clinic is connected: appointments, refills and records come from the
practice. If it isn&#39;t, or you use more than one clinic, you need an app that
keeps the records you enter yourself: PawVault, Pet Care Tracker (DogCat) or
11pets. I built PawVault, so weigh that; each section says where another app
is the better pick.</p>
<p>The short version:</p>
<ul>
<li><strong>Your clinic uses PetDesk:</strong> PetDesk.</li>
<li><strong>Your clinic is a VitusVet or Digitail partner:</strong> that app.</li>
<li><strong>Records from any clinic, certificates with expiry dates, a PDF for the
vet:</strong> PawVault.</li>
<li><strong>A free care calendar with diet, weight and symptoms:</strong> Pet Care Tracker
(DogCat).</li>
<li><strong>Several people looking after one pet:</strong> GreatPetCare.</li>
</ul>
<h2 id="how-the-seven-apps-compare"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#how-the-seven-apps-compare">How the seven apps compare</a></h2>
<div class="table"><table>
<thead>
<tr>
<th>App</th>
<th>Works without your vet joining</th>
<th>Your own documents</th>
<th>Reminders</th>
<th>Share with a vet</th>
<th>Price (US)</th>
<th>Ratings</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://apps.apple.com/us/app/pawvault-pet-health-records/id6779953779">PawVault</a></td>
<td>Yes</td>
<td>Yes, photos and PDFs with issue and expiry dates</td>
<td>Yes, repeating</td>
<td>Vet Summary PDF</td>
<td>Free for one pet; Pro $19.99/year</td>
<td>5.0 (8)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/petdesk/id631377773">PetDesk</a></td>
<td>Partly; built around your providers</td>
<td>Not stated</td>
<td>Synced from providers, plus your own</td>
<td>Through the provider</td>
<td>Free</td>
<td>4.9 (511,599)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/vitusvet-pet-medical-records/id955252538">VitusVet</a></td>
<td>Yes; appointments and refills need a partner practice</td>
<td>Records on your phone</td>
<td>Yes</td>
<td>Share with care providers</td>
<td>Free</td>
<td>4.9 (10,772)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/digitail-smarter-pet-care/id1473042508">Digitail</a></td>
<td>Mostly for clinics that use Digitail</td>
<td>Records and notes after each visit</td>
<td>Appointments and refills</td>
<td>Chat with your vet</td>
<td>Free</td>
<td>4.9 (4,052)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/greatpetcare/id934948619">GreatPetCare</a></td>
<td>Yes</td>
<td>Medical records in one place</td>
<td>Medication reminders</td>
<td>Caretaker network</td>
<td>Free</td>
<td>4.5 (2,165)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/pet-care-tracker-dog-cat-log/id1551003273">Pet Care Tracker (DogCat)</a></td>
<td>Yes</td>
<td>Health log and photo gallery</td>
<td>Calendar, vaccines and meds</td>
<td>Not stated</td>
<td>Free</td>
<td>4.8 (959)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/11pets-pet-care/id1232470530">11pets</a></td>
<td>Yes</td>
<td>Vaccination records and documents</td>
<td>Medication and appointments</td>
<td>Share records with your vet</td>
<td>Free</td>
<td>3.3 (77)</td>
</tr>
</tbody></table></div>
<p>Everything here is from each app&#39;s App Store listing in November 2026. &quot;Not
stated&quot; means the listing doesn&#39;t say.</p>
<h2 id="clinic-apps-and-owner-apps-are-different-tools"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#clinic-apps-and-owner-apps-are-different-tools">Clinic apps and owner apps are different tools</a></h2>
<p>A clinic app is the clinic&#39;s window to you. When the practice uses it, you
get appointment requests, refill requests, reminders the clinic sets and,
with some apps, the records from each visit, without typing anything. When it
doesn&#39;t, or you see a second clinic for emergencies, those records never
arrive.</p>
<p>An owner app is a folder you keep yourself. Nothing arrives automatically,
but it holds records from every clinic, the rabies certificate from the
breeder, the pet passport and the insurance policy, and it goes with you when
you move.</p>
<p>Most of the apps below lean one way. Many owners end up using their clinic&#39;s
app for bookings and an owner app for the full record.</p>
<h2 id="pawvault-your-own-record-from-any-clinic"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#pawvault-your-own-record-from-any-clinic">PawVault: your own record, from any clinic</a></h2>
<p><a href="https://apps.apple.com/us/app/pawvault-pet-health-records/id6779953779">PawVault</a>
keeps one record per pet: a profile with breed, weight, microchip number,
allergies and conditions; a health timeline of vaccinations, vet visits,
medications, lab tests, surgeries and symptoms; and a document vault for
vaccination certificates, passports, insurance, lab results and prescriptions
as photos or PDFs. Each document has an issue date and, where one is printed,
an expiry date; expired documents show in red.</p>
<p>Reminders repeat daily, weekly, monthly, yearly or every so many days, for
boosters, flea and tick treatment or doses. Vet Summary turns the record into
a PDF to send to a vet, a sitter or a new clinic. It works without signing
up; sign in to back up and sync to another iPhone, iPad or Mac.</p>
<p>The free version covers the timeline, documents, reminders and the PDF for one
pet. PawVault Pro ($19.99 a year on the US App Store, with a free trial) adds
unlimited pets and AI features: scanning a certificate to pre-fill the form,
turning typed notes into timeline entries, and describing a photo of a paw.
Nothing scanned is saved until you confirm it.</p>
<p><strong>Limits:</strong> it doesn&#39;t connect to clinics, so records are what you enter or
scan. It doesn&#39;t give medical advice or suggest vaccine schedules, it&#39;s in
English only, and data is stored in Firebase, not only on the phone.</p>
<figure><img src="https://treamz.me/blog/pet-health-records-app/documents.jpg" alt="PawVault documents screen listing a rabies vaccination certificate that expires May 2, 2029, blood panel results and a pet insurance policy that expires Jan 1, 2027" width="644" height="1400" loading="lazy" decoding="async"><figcaption>PawVault: each document with its issue and expiry date</figcaption></figure>
<h2 id="petdesk-if-your-clinic-uses-it"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#petdesk-if-your-clinic-uses-it">PetDesk: if your clinic uses it</a></h2>
<p><a href="https://apps.apple.com/us/app/petdesk/id631377773">PetDesk</a> is the most-rated
app here by a wide margin: 4.9 from over 500,000 ratings. It connects you with
your pet care providers (vet clinics, groomers, boarding, daycare): 24/7
appointment requests, reminders and messages synced to your calendar,
medication refill requests and loyalty points at participating locations.</p>
<p><strong>Pick it if</strong> your vet uses PetDesk. It&#39;s the clinic&#39;s channel, so it&#39;s
strongest for bookings and refills rather than as a full record of every
clinic you&#39;ve used.</p>
<h2 id="vitusvet-records-on-your-phone-with-partner-extras"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#vitusvet-records-on-your-phone-with-partner-extras">VitusVet: records on your phone, with partner extras</a></h2>
<p><a href="https://apps.apple.com/us/app/vitusvet-pet-medical-records/id955252538">VitusVet</a>
rates 4.9 from 10,772 ratings. It tracks weight and medications, logs
microchip and insurance details, notes allergies and medical alerts, and
keeps medical records on your phone to show a landlord, a dog park or a
groomer. Reminders cover medication, appointments and shopping. If your
practice partners with VitusVet, you can also request appointments, refills
and products in the app. It supports dogs, cats, rabbits, birds and reptiles.</p>
<p><strong>Pick it if</strong> you want records and reminders for free and there&#39;s a chance
your clinic partners with it.</p>
<h2 id="digitail-two-way-chat-with-your-vet"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#digitail-two-way-chat-with-your-vet">Digitail: two-way chat with your vet</a></h2>
<p><a href="https://apps.apple.com/us/app/digitail-smarter-pet-care/id1473042508">Digitail</a>
(4.9 from 4,052 ratings) is a digital medical record shared with your vet: you
book appointments, chat with the clinic, and see health records and notes
after each visit, plus day-to-day tracking that helps the vet. It&#39;s built for
clinics that run on Digitail.</p>
<p><strong>Pick it if</strong> your clinic uses Digitail and you want to message them.</p>
<h2 id="greatpetcare-for-a-pet-with-several-carers"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#greatpetcare-for-a-pet-with-several-carers">GreatPetCare: for a pet with several carers</a></h2>
<p><a href="https://apps.apple.com/us/app/greatpetcare/id934948619">GreatPetCare</a> (4.5 from
2,165 ratings) connects a pet&#39;s caretakers around one source: medical
records, feeding instructions, medication reminders and photos, plus a weight
tracker, product recall alerts and vet-reviewed articles.</p>
<p><strong>Pick it if</strong> a partner, a walker and a sitter all need the same
instructions.</p>
<h2 id="pet-care-tracker-dogcat-a-free-care-calendar"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#pet-care-tracker-dogcat-a-free-care-calendar">Pet Care Tracker (DogCat): a free care calendar</a></h2>
<p><a href="https://apps.apple.com/us/app/pet-care-tracker-dog-cat-log/id1551003273">Pet Care Tracker</a>,
also called DogCat, rates 4.8 from 959 ratings. It&#39;s a calendar-style agenda
for dogs and cats: a health log with a vaccination tracker and reminders,
medication schedules, diet, weight and symptom tracking, growth milestones
and a photo gallery. The listing calls it free.</p>
<p><strong>Pick it if</strong> daily care (feeding, weight, symptoms) matters as much as the
vet paperwork.</p>
<h2 id="11pets-an-older-all-rounder"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#11pets-an-older-all-rounder">11pets: an older all-rounder</a></h2>
<p><a href="https://apps.apple.com/us/app/11pets-pet-care/id1232470530">11pets</a> has been
around since 2017: medication reminders, appointment scheduling, weight and
vital signs, activities, vaccination records and documents, and sharing
records with your vet. The same company sells software to vets, groomers and
shelters. It rates 3.3 from 77 ratings.</p>
<p><strong>Pick it if</strong> you want weight and vital signs alongside records; read recent
reviews first.</p>
<h2 id="which-pet-health-app-should-you-use"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#which-pet-health-app-should-you-use">Which pet health app should you use?</a></h2>
<ul>
<li><strong>Your vet uses PetDesk, VitusVet or Digitail:</strong> start with that app.</li>
<li><strong>You use more than one clinic, travel, or need certificates at a
counter:</strong> PawVault, or VitusVet if you prefer free for several pets.</li>
<li><strong>You want daily care on a calendar:</strong> Pet Care Tracker (DogCat).</li>
<li><strong>Several people share the care:</strong> GreatPetCare.</li>
</ul>
<h2 id="what-to-keep-whichever-app-you-use"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#what-to-keep-whichever-app-you-use">What to keep, whichever app you use</a></h2>
<ol>
<li><strong>Vaccination certificates,</strong> above all rabies, with the date given and the
date your vet says the next is due.</li>
<li><strong>The microchip number,</strong> allergies and long-term conditions.</li>
<li><strong>Current medications</strong> with doses.</li>
<li><strong>Recent lab results</strong> and the last few vet visit summaries.</li>
<li><strong>Insurance and pet passport details</strong> if you travel. For the US, the
<a href="https://www.aphis.usda.gov/pet-travel">USDA APHIS pet travel pages</a>
explain the rules by destination.</li>
</ol>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#faq">FAQ</a></h2>
<h3 id="what-is-the-best-app-to-keep-my-pets-health-records"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#what-is-the-best-app-to-keep-my-pets-health-records">What is the best app to keep my pet&#39;s health records?</a></h3>
<p>If your vet clinic uses PetDesk, VitusVet or Digitail, its app brings
appointments, refills and sometimes visit records to you. To keep records from
any clinic yourself, PawVault stores vaccinations, vet visits, medications and
certificates with expiry dates and exports a PDF for the vet; VitusVet and Pet
Care Tracker (DogCat) are free alternatives.</p>
<h3 id="is-there-an-app-that-reminds-me-when-my-dogs-vaccines-are-due"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#is-there-an-app-that-reminds-me-when-my-dogs-vaccines-are-due">Is there an app that reminds me when my dog&#39;s vaccines are due?</a></h3>
<p>Yes. PawVault, VitusVet, Pet Care Tracker (DogCat) and 11pets all set
reminders for vaccines and medication, and PetDesk syncs reminders from
participating clinics. The due dates come from your vet; none of these apps
should set a vaccine schedule for you.</p>
<h3 id="can-i-store-my-pets-vaccination-certificate-on-my-phone"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#can-i-store-my-pets-vaccination-certificate-on-my-phone">Can I store my pet&#39;s vaccination certificate on my phone?</a></h3>
<p>Yes. PawVault keeps certificates as photos or PDFs with their issue and expiry
dates and marks expired ones in red; PawVault Pro can scan a certificate to
fill in the details. VitusVet and 11pets also keep vaccination records on your
phone.</p>
<h3 id="which-pet-health-app-works-without-my-vet-clinic"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#which-pet-health-app-works-without-my-vet-clinic">Which pet health app works without my vet clinic?</a></h3>
<p>PawVault, VitusVet, GreatPetCare, Pet Care Tracker (DogCat) and 11pets all work
with records you enter yourself. PetDesk and Digitail are built around clinics
that use them.</p>
<h3 id="is-there-a-free-pet-health-record-app"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#is-there-a-free-pet-health-record-app">Is there a free pet health record app?</a></h3>
<p>PetDesk, VitusVet, Digitail, GreatPetCare, Pet Care Tracker (DogCat) and 11pets
are free to download. PawVault is free for one pet, with PawVault Pro at
$19.99 a year for unlimited pets and the AI features.</p>
<h3 id="how-do-i-share-my-pets-records-with-a-new-vet"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#how-do-i-share-my-pets-records-with-a-new-vet">How do I share my pet&#39;s records with a new vet?</a></h3>
<p>Bring or send the vaccination certificates, medications, allergies, microchip
number and recent lab results. PawVault&#39;s Vet Summary turns them into one PDF
to send by AirDrop, Mail or Messages; VitusVet and 11pets also share records
with care providers.</p>
<h3 id="does-pawvault-give-medical-advice"><a class="anchor" href="https://treamz.me/blog/pet-health-record-apps-compared#does-pawvault-give-medical-advice">Does PawVault give medical advice?</a></h3>
<p>No. PawVault keeps records and sends reminders; it doesn&#39;t diagnose, recommend
treatment or set vaccination schedules. Your vet decides those.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Sourdough planner apps that work back from bake time: 7 compared</title>
      <link>https://treamz.me/blog/sourdough-planner-apps-compared</link>
      <guid isPermaLink="true">https://treamz.me/blog/sourdough-planner-apps-compared</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <description>Seven iPhone sourdough apps compared: which plan backwards from when you want bread, adjust for kitchen temperature, keep steps out of the night, and cost.</description>
      <category>Sourdough</category>
      <category>Baking</category>
      <category>Bake Planner</category>
      <category>iOS</category>
      <category>App Comparison</category>
      <content:encoded><![CDATA[<p>If you want an app that tells you when to feed your starter, mix and bake so
the bread is ready at a set time, five of the seven iPhone apps below do it:
Proofday, Sourdough Planner (Bulk Planner), BreadMe, BackPlaner and Dough
Timer. They differ on three things that decide whether the plan survives your
kitchen: whether it adjusts for temperature, whether it keeps steps out of the
night, and whether it lets you bring your own recipe. I built Proofday, so
weigh that, but each section below says where another app is the better pick.</p>
<p>The short version:</p>
<ul>
<li><strong>Planned around temperature and sleep:</strong> Proofday.</li>
<li><strong>One-off price, bread and pizza:</strong> Sourdough Planner ($2.99).</li>
<li><strong>Your own recipes, several bakes on one day:</strong> BackPlaner (free).</li>
<li><strong>A simple backward timer:</strong> Dough Timer (free).</li>
<li><strong>Your recipes plus starter reminders:</strong> BreadMe ($2.99).</li>
<li><strong>Starter tracking and loaf feedback, no schedule:</strong> Loaflo (free download).</li>
</ul>
<h2 id="how-the-seven-apps-compare"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#how-the-seven-apps-compare">How the seven apps compare</a></h2>
<div class="table"><table>
<thead>
<tr>
<th>App</th>
<th>Plans back from a ready time</th>
<th>Adjusts for kitchen temperature</th>
<th>Keeps steps out of the night</th>
<th>Own recipes</th>
<th>Price (US)</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://apps.apple.com/us/app/proofday-sourdough-planner/id6826169140">Proofday</a></td>
<td>Yes</td>
<td>Yes, and learns from your bakes</td>
<td>Yes, quiet hours</td>
<td>No, 8 built-in</td>
<td>Free download; $39.99/year after a 7-day trial</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/sourdough-planner/id6771627451">Sourdough Planner</a></td>
<td>Yes</td>
<td>Room temperature is an input</td>
<td>Not mentioned</td>
<td>Saved formulas</td>
<td>$2.99 once</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/breadme-sourdough-planner/id1522824861">BreadMe</a></td>
<td>Yes</td>
<td>Not mentioned</td>
<td>No</td>
<td>Yes, shareable</td>
<td>$2.99 once</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/backplaner/id978993791">BackPlaner</a></td>
<td>Yes, several bakes at once</td>
<td>Not mentioned</td>
<td>Yes, shows a night-free window</td>
<td>Yes</td>
<td>Free download</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/dough-timer/id6755980237">Dough Timer</a></td>
<td>Yes</td>
<td>No</td>
<td>No</td>
<td>Edit step durations</td>
<td>Free</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/bread-boss/id1304204584">Bread Boss</a></td>
<td>Shift alarms by hand</td>
<td>No</td>
<td>No</td>
<td>Yes, baker&#39;s formulas</td>
<td>$5.99 once</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/loaflo-sourdough-starter/id6736766728">Loaflo</a></td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>Recipes and calculators</td>
<td>Free download</td>
</tr>
</tbody></table></div>
<p>Everything here comes from each app&#39;s App Store listing in November 2026;
&quot;not mentioned&quot; means the listing doesn&#39;t say. I left out two apps that AI
answers still recommend, Crumb and Levain, because I couldn&#39;t find either on
the App Store any more.</p>
<h2 id="what-a-sourdough-planner-has-to-get-right"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#what-a-sourdough-planner-has-to-get-right">What a sourdough planner has to get right</a></h2>
<p>A bake runs a day and a half, and three things move the clock.</p>
<ol>
<li><strong>The starter&#39;s peak.</strong> It goes into the dough near its peak, and that
depends on the feed ratio and the warmth of the jar.</li>
<li><strong>Temperature.</strong> Bulk fermentation is chemistry: a common rule of thumb,
and the one Proofday uses, is that every 7 °C (about 13 °F) cooler roughly
doubles the time. A plan built for 24 °C (75 °F) runs hours late at 18 °C
(64 °F).</li>
<li><strong>The night.</strong> Count back from Saturday breakfast and the folds land at
3 AM unless something stretches. The cold proof in the fridge is the usual
buffer, because it can run anywhere from about 8 to 24 hours.</li>
</ol>
<p>An app that only subtracts fixed durations from your ready time gets the
arithmetic right and the bread wrong on a cold day. That&#39;s the main thing to
check before you rely on one.</p>
<h2 id="proofday-built-around-temperature-and-sleep"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#proofday-built-around-temperature-and-sleep">Proofday: built around temperature and sleep</a></h2>
<p><a href="https://apps.apple.com/us/app/proofday-sourdough-planner/id6826169140">Proofday</a>
plans backwards from &quot;bread ready Saturday at 10&quot; and adjusts every step for
your kitchen temperature. Hands-on steps stay out of your quiet hours (11 PM
to 7 AM by default): it stretches the cold proof, or switches to a slower
overnight starter feed, to bridge the night. Ask for a time that can&#39;t work
and it says why and offers the nearest one that can.</p>
<p>It predicts when your starter will peak from the feed ratio and temperature,
measures rise from photos of the jar, and tunes both to your kitchen after a
few bakes. During the bake there&#39;s a reminder for every step with Done, +15
min and Snooze on the Lock Screen, a Live Activity countdown, and &quot;Need more
time&quot; to push the rest of the plan.</p>
<p><strong>Limits:</strong> no recipe editor yet (you choose from eight built-in recipes),
iPhone only, English only, and after the first planned bake it needs a
subscription: $39.99 a year after a 7-day free trial, or $6.99 a week.</p>
<figure><img src="https://treamz.me/blog/sourdough-schedule-planner-app/plan.jpg" alt="Proofday&#39;s plan for a loaf ready Saturday at 10 AM" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Proofday: bread at 10 on Saturday, worked back from the ready time</figcaption></figure>
<h2 id="sourdough-planner-bulk-planner-one-price-bread-and-pizza"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#sourdough-planner-bulk-planner-one-price-bread-and-pizza">Sourdough Planner (Bulk Planner): one price, bread and pizza</a></h2>
<p><a href="https://apps.apple.com/us/app/sourdough-planner/id6771627451">Sourdough Planner</a>,
which calls itself Bulk Planner inside, takes a target bake time, dough style,
room temperature, starter timing and cold-proof path, and builds the schedule
backwards. It has same-day, short cold, 12-hour and 16-hour plans, a pizza
path that can end in frozen dough balls, formula scaling and fresh-milled
flour support, alarms, bake logs and iCloud sync.</p>
<p><strong>Pick it if</strong> you want a temperature-aware backward planner for a one-off
$2.99, or you make pizza dough as often as bread. It&#39;s new (first released in
May 2026) and had no ratings when I checked.</p>
<h2 id="breadme-your-own-recipes-scheduled"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#breadme-your-own-recipes-scheduled">BreadMe: your own recipes, scheduled</a></h2>
<p><a href="https://apps.apple.com/us/app/breadme-sourdough-planner/id1522824861">BreadMe</a>
schedules a recipe so the bread is ready when you need it, tracks progress
through it, keeps your recipe collection and shares it with friends. It also
tracks your sourdough starters and reminds you to feed them. It has been on
the App Store since 2020 and rates 4.7 from 40 ratings.</p>
<p><strong>Pick it if</strong> you bake from your own recipes and want them scheduled, for a
one-off $2.99. The listing doesn&#39;t mention temperature adjustment or night
hours.</p>
<h2 id="backplaner-several-bakes-and-a-window-with-no-night-steps"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#backplaner-several-bakes-and-a-window-with-no-night-steps">BackPlaner: several bakes, and a window with no night steps</a></h2>
<p><a href="https://apps.apple.com/us/app/backplaner/id978993791">BackPlaner</a> plans
backwards from when each bake should finish and puts every step of every bake
in one chronological list, colour-coded by bake, so a loaf, a flatbread and a
pizza can share a day. Move one step and the later ones move with it. For long
sourdough recipes it shows the window in which no step falls into your night
hours, which you set. You write your own recipes with any number of steps, it
scales ingredients and calculates hydration.</p>
<p><strong>Pick it if</strong> you bake several things on the same day or want full control
of your own recipe. It doesn&#39;t model temperature: your durations are fixed
until you change them.</p>
<h2 id="dough-timer-a-simple-backward-timer"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#dough-timer-a-simple-backward-timer">Dough Timer: a simple backward timer</a></h2>
<p><a href="https://apps.apple.com/us/app/dough-timer/id6755980237">Dough Timer</a> takes a
target finish time and calculates your start time and every stage: pre-ferment,
autolyse, mix, bulk, folds, shape and proof. You can edit durations, reorder or
skip stages, and follow a fold mode for stretch-and-folds, with Live Activities
on the Lock Screen and Apple Watch. It&#39;s free.</p>
<p><strong>Pick it if</strong> you already know your timings and just want the subtraction
done and a timer to follow. It doesn&#39;t adjust for temperature or avoid the
night.</p>
<h2 id="bread-boss-bakers-formulas-and-alarms"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#bread-boss-bakers-formulas-and-alarms">Bread Boss: baker&#39;s formulas and alarms</a></h2>
<p><a href="https://apps.apple.com/us/app/bread-boss/id1304204584">Bread Boss</a> turns
recipes into baker&#39;s percentages, scales them, ships with sourdough recipes
from a master baker, and sets alarms for each task that you can shift forward
or back to fit your day. It costs $5.99 and was last updated in September 2023.</p>
<p><strong>Pick it if</strong> formulas and scaling matter more to you than automatic
scheduling, and you don&#39;t mind an app that isn&#39;t being updated.</p>
<h2 id="loaflo-starter-tracking-and-ai-feedback"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#loaflo-starter-tracking-and-ai-feedback">Loaflo: starter tracking and AI feedback</a></h2>
<p><a href="https://apps.apple.com/us/app/loaflo-sourdough-starter/id6736766728">Loaflo</a>
is the most-rated app here (4.7 from 573 ratings). It tracks starter feedings
and hydration, analyses photos of your loaf or starter with AI, has recipes,
calculators and a chat assistant, and needs no account. It doesn&#39;t build a
schedule back from a ready time.</p>
<p><strong>Pick it if</strong> you want help with the starter and feedback on your crumb more
than a timetable. Some people use it alongside a planner.</p>
<h2 id="how-to-plan-a-sourdough-bake-backwards-without-an-app"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#how-to-plan-a-sourdough-bake-backwards-without-an-app">How to plan a sourdough bake backwards without an app</a></h2>
<ol>
<li><strong>Pick the ready time</strong> and subtract cooling (at least an hour, two is
better) and baking (about 45 minutes in a Dutch oven, plus preheating).</li>
<li><strong>Put the cold proof overnight.</strong> Shape in the evening, bake in the
morning: 8 to 24 hours in the fridge absorbs most timing errors.</li>
<li><strong>Count back bulk fermentation for your kitchen.</strong> If your recipe says 5
hours at 24 °C (75 °F), expect about 7.5 at 20 °C (68 °F) and 9 at 18 °C
(64 °F).</li>
<li><strong>Feed the starter so it peaks when you mix.</strong> In Proofday&#39;s model, a 1:2:2
feed peaks about 7 hours later at 24 °C, and later in a cooler kitchen.</li>
<li><strong>Watch the dough, not the clock.</strong> A small jar of dough set aside at
mixing shows its rise better than the bowl does.</li>
</ol>
<h2 id="which-sourdough-planner-should-you-use"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#which-sourdough-planner-should-you-use">Which sourdough planner should you use?</a></h2>
<ul>
<li><strong>You bake one loaf at a time and your kitchen temperature changes with the
season:</strong> Proofday.</li>
<li><strong>You want to pay once:</strong> Sourdough Planner or BreadMe, $2.99 each.</li>
<li><strong>You bake your own recipes or several breads on one day:</strong> BackPlaner.</li>
<li><strong>You know your timings and want a free timer:</strong> Dough Timer.</li>
<li><strong>You mostly need help with the starter:</strong> Loaflo.</li>
</ul>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#faq">FAQ</a></h2>
<h3 id="is-there-an-app-that-plans-a-sourdough-bake-backwards-from-when-i-want-bread"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#is-there-an-app-that-plans-a-sourdough-bake-backwards-from-when-i-want-bread">Is there an app that plans a sourdough bake backwards from when I want bread?</a></h3>
<p>Yes. On iPhone, Proofday, Sourdough Planner (Bulk Planner), BreadMe,
BackPlaner and Dough Timer all take a ready time and work out when to start.
Proofday and Sourdough Planner also take your kitchen temperature into
account; Proofday and BackPlaner keep steps out of the night.</p>
<h3 id="what-is-the-best-free-sourdough-schedule-app"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#what-is-the-best-free-sourdough-schedule-app">What is the best free sourdough schedule app?</a></h3>
<p>Among the apps compared here, BackPlaner and Dough Timer are free to download
and both plan backwards from a finish time. BackPlaner handles several bakes at
once and shows a window with no steps during the night; Dough Timer is a
simpler timer with Live Activities. Neither adjusts for temperature.</p>
<h3 id="which-sourdough-app-adjusts-the-schedule-for-kitchen-temperature"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#which-sourdough-app-adjusts-the-schedule-for-kitchen-temperature">Which sourdough app adjusts the schedule for kitchen temperature?</a></h3>
<p>Proofday adjusts every step for the kitchen temperature you set, assuming
fermentation time roughly doubles for every 7 °C (13 °F) cooler, and tunes the
model to your logged bakes. Sourdough Planner (Bulk Planner) also asks for room
temperature when it builds a plan.</p>
<h3 id="how-do-i-bake-sourdough-without-getting-up-at-night"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#how-do-i-bake-sourdough-without-getting-up-at-night">How do I bake sourdough without getting up at night?</a></h3>
<p>Put the long, flexible step at night: shape in the evening and cold proof in
the fridge for 8 to 24 hours, then bake in the morning. Proofday plans this
automatically around quiet hours you set, and BackPlaner shows the window in
which no step falls into your night hours.</p>
<h3 id="is-there-a-one-time-purchase-sourdough-planner"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#is-there-a-one-time-purchase-sourdough-planner">Is there a one-time-purchase sourdough planner?</a></h3>
<p>Yes. Sourdough Planner (Bulk Planner) and BreadMe each cost $2.99 once on the
App Store, and Bread Boss costs $5.99. Proofday is free to download but needs a
subscription after the first planned bake.</p>
<h3 id="are-there-sourdough-planner-apps-for-android"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#are-there-sourdough-planner-apps-for-android">Are there sourdough planner apps for Android?</a></h3>
<p>The apps compared here are for iPhone. On Google Play, Sourdough Timer: Bake
Planner also builds a schedule backwards from a bake time, with ads in its
free version.</p>
<h3 id="is-proofday-free"><a class="anchor" href="https://treamz.me/blog/sourdough-planner-apps-compared#is-proofday-free">Is Proofday free?</a></h3>
<p>Proofday is free to download, and its setup quiz plans your first bake. After
that it needs Proofday Pro: $39.99 a year after a 7-day free trial, or $6.99 a
week (US prices).</p>
]]></content:encoded>
    </item>
    <item>
      <title>TCG card scanner apps that price your collection: 8 compared</title>
      <link>https://treamz.me/blog/tcg-card-scanner-apps-compared</link>
      <guid isPermaLink="true">https://treamz.me/blog/tcg-card-scanner-apps-compared</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 GMT</pubDate>
      <description>Eight iPhone apps that scan trading cards and value a collection, compared: exact printing, scan limits, where prices come from, account and cost.</description>
      <category>Trading cards</category>
      <category>Card scanner</category>
      <category>Collection tracking</category>
      <category>iOS</category>
      <category>App Comparison</category>
      <content:encoded><![CDATA[<p>If you want an app that scans your trading cards and tells you what the
collection is worth, the big names are Collectr, TCGplayer, Ludex,
PriceCharting and HoloDex, and for a single game there are specialists like
Dex. They differ on four things that decide whether the total is right:
whether the scanner pins down the exact printing, whether scanning is
metered, where the prices come from, and whether graded and sealed items are
kept apart. I built one of the eight, Foilcase, so weigh that. Each section
says where another app is the better pick.</p>
<p>The short version:</p>
<ul>
<li><strong>The most games, raw, graded and sealed in one portfolio:</strong> Collectr.</li>
<li><strong>Free scanning and prices, and buying cards:</strong> TCGplayer.</li>
<li><strong>Sports cards as well as TCG, and selling on eBay:</strong> Ludex.</li>
<li><strong>Free sold-price data by condition, plus games, comics and coins:</strong>
PriceCharting.</li>
<li><strong>AI condition checks before grading:</strong> HoloDex.</li>
<li><strong>One Pokémon collection down to the variant:</strong> Dex.</li>
<li><strong>Asks instead of guessing the print, no scan limit, no account:</strong> Foilcase.</li>
</ul>
<h2 id="how-the-eight-apps-compare"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#how-the-eight-apps-compare">How the eight apps compare</a></h2>
<div class="table"><table>
<thead>
<tr>
<th>App</th>
<th>Games</th>
<th>Scan limit</th>
<th>Price data</th>
<th>Graded and sealed</th>
<th>Account</th>
<th>Cost (US)</th>
<th>Ratings</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://apps.apple.com/us/app/collectr-tcg-collector-app/id1603892248">Collectr</a></td>
<td>20+ TCGs and Funko</td>
<td>Unlimited with in-app purchase</td>
<td>Its 1M+ product database</td>
<td>Both</td>
<td>Not stated</td>
<td>Free; paid tier</td>
<td>4.9 (69,892)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/tcgplayer/id1247645833">TCGplayer</a></td>
<td>40+ TCGs</td>
<td>Free</td>
<td>TCGplayer market prices</td>
<td>Not stated</td>
<td>Yes, to keep a collection</td>
<td>Free</td>
<td>4.3 (18,727)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/ludex-sports-card-scanner-tcg/id1616691213">Ludex</a></td>
<td>Sports cards, Pokémon, Magic and more</td>
<td>Unlimited with a subscription</td>
<td>eBay and other completed sales</td>
<td>Not stated</td>
<td>Not stated</td>
<td>Free; subscription</td>
<td>4.7 (28,184)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/pricecharting-tcg-collector/id6452190948">PriceCharting</a></td>
<td>TCG, sports, games, comics, coins</td>
<td>Photo search, free</td>
<td>Sales from eBay, Heritage, PWCC, by condition</td>
<td>Prices for every condition</td>
<td>Not stated</td>
<td>Free</td>
<td>4.8 (27,757)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/holodex-tcg-scan-collect/id6747442689">HoloDex</a></td>
<td>11 TCGs</td>
<td>Unlimited with in-app purchase</td>
<td>Live market prices</td>
<td>Not stated</td>
<td>Syncs across devices</td>
<td>Free; paid tier</td>
<td>4.8 (25,250)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/dex-for-tcg-collectors/id1555489854">Dex</a></td>
<td>Pokémon only</td>
<td>Not stated</td>
<td>TCGplayer, Cardmarket, eBay, CardTrader</td>
<td>Not stated</td>
<td>Not stated</td>
<td>Free; Dex+ subscription</td>
<td>4.8 (14,548)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/pok%C3%A9-tcg-scanner-dragon-shield/id1199495742">Dragon Shield</a></td>
<td>Pokémon</td>
<td>Not stated</td>
<td>TCGplayer and Cardmarket, daily</td>
<td>Not stated</td>
<td>Friends features</td>
<td>Free</td>
<td>4.6 (13,828)</td>
</tr>
<tr>
<td><a href="https://apps.apple.com/us/app/foilcase-tcg-card-scanner/id6813665558">Foilcase</a></td>
<td>5 TCGs</td>
<td>None</td>
<td>Public marketplace data, daily, with source and date</td>
<td>Kept apart; graded valued by you</td>
<td>No</td>
<td>14-day trial, then $7.99/month or $49.99/year</td>
<td>New</td>
</tr>
</tbody></table></div>
<p>Everything here is from each app&#39;s App Store listing in November 2026. &quot;Not
stated&quot; means the listing doesn&#39;t say.</p>
<h2 id="why-the-exact-printing-decides-the-total"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#why-the-exact-printing-decides-the-total">Why the exact printing decides the total</a></h2>
<p>The same card is often printed more than once: regular, foil, alternate art, a
higher-rarity parallel. Some share an illustration, some even share a
collector number, and their prices can differ by multiples. A scanner that
recognises the picture names the card and can still hand you the price of the
wrong print.</p>
<p>How often? On a test set where half the cards shared a collector number with
another print, matching the artwork picked the right print 89.5% of the time
even when it was given the correct number
(<a href="https://treamz.me/blog/identifying-card-printings-on-device">how I measured it</a>). One miss in
ten is fine for looking a card up and bad for a collection total. So whatever
app you use, check how it handles look-alike prints and foils, and whether it
tells you when it isn&#39;t sure.</p>
<p>Three more things move the total: whether prices are asking prices or
completed sales, how old they are, and whether a graded slab is valued as if
it were a loose card.</p>
<h2 id="collectr-the-most-games-in-one-portfolio"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#collectr-the-most-games-in-one-portfolio">Collectr: the most games in one portfolio</a></h2>
<p><a href="https://apps.apple.com/us/app/collectr-tcg-collector-app/id1603892248">Collectr</a>
is the most-rated app here by far: 4.9 from almost 70,000 ratings. It covers
more than twenty TCGs, from Magic, Pokémon, Yu-Gi-Oh! and One Piece to Lorcana,
Flesh and Blood and Digimon, plus Funko, and treats a collection as a
portfolio of raw, graded and sealed items valued from a database of over a
million products. It shows biggest gains and losses and works in any currency,
crypto included. Unlimited scanning, data export and some filters need an
in-app purchase.</p>
<p><strong>Pick it if</strong> you collect several games and want them in one portfolio.</p>
<h2 id="tcgplayer-free-scanning-from-the-marketplace-itself"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#tcgplayer-free-scanning-from-the-marketplace-itself">TCGplayer: free scanning, from the marketplace itself</a></h2>
<p><a href="https://apps.apple.com/us/app/tcgplayer/id1247645833">TCGplayer</a> is the
marketplace&#39;s own app. Scanning, organising and market price tracking are free
for every card on TCGplayer, which covers more than forty games. You log in to
keep your collection, and you can buy from thousands of sellers in the same
app. It needs iOS 18 or later.</p>
<p><strong>Pick it if</strong> you want free scanning and price tracking and buy singles on
TCGplayer anyway.</p>
<h2 id="ludex-sports-cards-and-tcg-with-ebay-listing"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#ludex-sports-cards-and-tcg-with-ebay-listing">Ludex: sports cards and TCG, with eBay listing</a></h2>
<p><a href="https://apps.apple.com/us/app/ludex-sports-card-scanner-tcg/id1616691213">Ludex</a>
scans sports cards (baseball, basketball, football, soccer, hockey and more)
as well as Pokémon and Magic, says it handles variations and parallels, and
values cards from completed sales on eBay and other marketplaces. Its List-It
feature lists cards on eBay for you, and it organises cards into binders and
tracks set completion. Unlimited scans come with a subscription.</p>
<p><strong>Pick it if</strong> you collect sports cards too, or you sell on eBay.</p>
<h2 id="pricecharting-free-sold-prices-by-condition"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#pricecharting-free-sold-prices-by-condition">PriceCharting: free sold prices by condition</a></h2>
<p><a href="https://apps.apple.com/us/app/pricecharting-tcg-collector/id6452190948">PriceCharting</a>
is free and built on sales: it tracks completed sales from eBay, Heritage,
PWCC and others and sorts them into price guides for every condition. You can identify a card by photo or barcode, see past sales, track a
collection and a wishlist, get alerts when a wishlist item is listed below
market value, and get grading recommendations. It also covers video games,
comics, coins and LEGO.</p>
<p><strong>Pick it if</strong> you want to see what cards actually sold for, by condition, for
free.</p>
<h2 id="holodex-ai-condition-checks-before-you-grade"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#holodex-ai-condition-checks-before-you-grade">HoloDex: AI condition checks before you grade</a></h2>
<p><a href="https://apps.apple.com/us/app/holodex-tcg-scan-collect/id6747442689">HoloDex</a>
scans eleven TCGs, including Pokémon, One Piece, Lorcana, Yu-Gi-Oh!, Magic and
Gundam, and adds AI grading insights: it looks for whitening, centering
problems and surface wear before you send a card off. It tracks prices,
profits and losses, and syncs your collection between devices. Unlimited scans
and AI grading are in-app purchases.</p>
<p><strong>Pick it if</strong> you&#39;re deciding which cards are worth sending for grading.</p>
<h2 id="dex-one-pokémon-collection-down-to-the-variant"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#dex-one-pokémon-collection-down-to-the-variant">Dex: one Pokémon collection, down to the variant</a></h2>
<p><a href="https://apps.apple.com/us/app/dex-for-tcg-collectors/id1555489854">Dex</a> is
Pokémon only and goes deep: it tracks cards down to the exact variant, covers
English, Japanese and Simplified Chinese sets, and can sweep a whole binder
page or stack in one shot. Prices from TCGplayer, Cardmarket, eBay and
CardTrader are free for everyone. There are virtual binders, a Pokédex binder,
a deck builder and a Dex+ subscription.</p>
<p><strong>Pick it if</strong> you collect Pokémon and want set completion and binders more
than a cross-game portfolio.</p>
<h2 id="dragon-shield-pokémon-prices-decks-and-trades"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#dragon-shield-pokémon-prices-decks-and-trades">Dragon Shield: Pokémon prices, decks and trades</a></h2>
<p><a href="https://apps.apple.com/us/app/pok%C3%A9-tcg-scanner-dragon-shield/id1199495742">Dragon Shield&#39;s Pokémon TCG scanner</a>
checks daily prices from TCGplayer and Cardmarket, organises cards into
folders, builds decks, compares the value of a trade between two players, and
exports a collection to CSV or text. You can see friends&#39; collections, decks
and trade lists.</p>
<p><strong>Pick it if</strong> you play as well as collect and want decks and trades next to
prices, with both US and European price sources.</p>
<h2 id="foilcase-asks-instead-of-guessing-no-scan-limit"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#foilcase-asks-instead-of-guessing-no-scan-limit">Foilcase: asks instead of guessing, no scan limit</a></h2>
<p><a href="https://apps.apple.com/us/app/foilcase-tcg-card-scanner/id6813665558">Foilcase</a>
reads the artwork and the printed collector number together, on the phone,
and adds a card by itself only when both agree. For a look-alike print, an
alternate art or an unreadable number it shows the candidates and asks, once:
a playset of four is one question. It never guesses foil, because no photo
reliably shows the finish. Scanning is never metered: no per-card counter, no
credits, no scan packs.</p>
<p>Prices are collected daily from public marketplace data, in US dollars, and
every figure shows its source and date; after seven days the portfolio says
the prices are old. Raw, graded and sealed are separate items, and a graded
card is never valued from its raw price: you enter a value or it stays
unpriced. The portfolio shows paid, worth now, gain and return. There&#39;s no
account, no ads, and the App Store label says Data Not Collected.</p>
<p><strong>Limits:</strong> five games, a subscription after the 14-day trial ($7.99 a month
or $49.99 a year), no cloud sync or export yet, no condition grading from a
photo and no sold-listing comps. It&#39;s new, with no ratings yet.</p>
<figure><img src="https://treamz.me/blog/trading-card-scanner-app/portfolio.jpg" alt="Foilcase Portfolio screen showing what was paid, what the cards are worth now, the unrealized gain and the return, with the price source and date at the bottom" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Foilcase: paid, worth now, gain and return, with the price date underneath</figcaption></figure>
<h2 id="which-card-scanner-app-should-you-use"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#which-card-scanner-app-should-you-use">Which card scanner app should you use?</a></h2>
<ul>
<li><strong>You collect several games:</strong> Collectr, or TCGplayer if you want scanning
free.</li>
<li><strong>You collect sports cards too, or sell on eBay:</strong> Ludex.</li>
<li><strong>You care about sold prices by condition:</strong> PriceCharting.</li>
<li><strong>You&#39;re about to grade:</strong> HoloDex for condition checks, PriceCharting for
prices by condition and grading recommendations.</li>
<li><strong>You collect only Pokémon:</strong> Dex, or Dragon Shield if you also play.</li>
<li><strong>You want the right print and no scan limit, without an account:</strong>
Foilcase.</li>
</ul>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#faq">FAQ</a></h2>
<h3 id="what-is-the-best-app-to-scan-trading-cards-and-see-what-theyre-worth"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#what-is-the-best-app-to-scan-trading-cards-and-see-what-theyre-worth">What is the best app to scan trading cards and see what they&#39;re worth?</a></h3>
<p>For most collectors, Collectr, TCGplayer, Ludex, PriceCharting and HoloDex all
scan cards and value a collection. Collectr covers the most games, TCGplayer
scans for free, Ludex adds sports cards, PriceCharting shows sold prices by
condition for free, and Foilcase asks which print you own instead of guessing.</p>
<h3 id="which-card-scanner-app-is-free"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#which-card-scanner-app-is-free">Which card scanner app is free?</a></h3>
<p>TCGplayer&#39;s scanning, collection and price tracking are free, and PriceCharting
is free, with photo search and sold-price data. Collectr and HoloDex are free
to download but put unlimited scanning behind an in-app purchase; Ludex
includes unlimited scans with a subscription.</p>
<h3 id="is-there-a-card-scanner-app-without-scan-limits"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#is-there-a-card-scanner-app-without-scan-limits">Is there a card scanner app without scan limits?</a></h3>
<p>TCGplayer&#39;s scanner is free to use. Foilcase doesn&#39;t meter scanning at all, no
per-card counter, credits or scan packs, as part of its subscription. Collectr
and HoloDex unlock unlimited scans with an in-app purchase, and Ludex with a
subscription.</p>
<h3 id="how-do-i-know-which-printing-of-a-card-i-have"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#how-do-i-know-which-printing-of-a-card-i-have">How do I know which printing of a card I have?</a></h3>
<p>Check the set code and collector number printed on the card, then the rarity
mark and whether it is foil, because two prints can share an illustration and
even a number. Foilcase reads the artwork and number together and asks when
more than one print could match; Dex tracks Pokémon cards down to the variant.</p>
<h3 id="which-app-shows-what-cards-actually-sold-for"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#which-app-shows-what-cards-actually-sold-for">Which app shows what cards actually sold for?</a></h3>
<p>PriceCharting sorts completed sales from eBay, Heritage, PWCC and other sites
into price guides for every condition. Ludex values cards
from completed sales on eBay and other marketplaces.</p>
<h3 id="can-a-card-scanner-app-grade-my-cards"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#can-a-card-scanner-app-grade-my-cards">Can a card scanner app grade my cards?</a></h3>
<p>Not officially. HoloDex gives AI condition insights such as whitening,
centering and surface wear, and PriceCharting gives grading recommendations,
but only a grading company assigns a grade.</p>
<h3 id="does-foilcase-need-an-account"><a class="anchor" href="https://treamz.me/blog/tcg-card-scanner-apps-compared#does-foilcase-need-an-account">Does Foilcase need an account?</a></h3>
<p>No. Foilcase has no account, no ads and no analytics; the collection is stored
on the iPhone in an append-only ledger and included in encrypted device
backups. There is no cloud sync yet.</p>
]]></content:encoded>
    </item>
    <item>
      <title>A free aquarium water test log app for iPhone</title>
      <link>https://treamz.me/blog/aquarium-water-test-log-app</link>
      <guid isPermaLink="true">https://treamz.me/blog/aquarium-water-test-log-app</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>Aquarium - Log Tracker is a free iPhone app for logging water tests, water changes and maintenance for freshwater and saltwater tanks. No account needed.</description>
      <category>Aquarium</category>
      <category>Fishkeeping</category>
      <category>Water Testing</category>
      <category>iOS</category>
      <category>Flutter</category>
      <content:encoded><![CDATA[<p>If you want an app to log your aquarium water tests, this is the one I built:
<a href="https://apps.apple.com/us/app/aquarium-log-tracker/id6804384133">Aquarium - Log Tracker</a>,
free to download for iPhone, with no in-app purchases, subscription or ads. It
records ammonia, nitrite, nitrate, pH, temperature and whatever else your kits
measure, plus water changes, maintenance and livestock, for freshwater and
saltwater tanks, one or several. It works fully without an account.</p>
<p>I build it on my own. Here&#39;s what it does and doesn&#39;t do.</p>
<h2 id="why-keep-a-log-of-aquarium-water-tests"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#why-keep-a-log-of-aquarium-water-tests">Why keep a log of aquarium water tests?</a></h2>
<p>One test tells you where the tank is today. A log tells you which way it&#39;s
going: nitrate creeping up between water changes, alkalinity swinging, a heater
that sticks. When something dies, last week&#39;s readings are what explain it.</p>
<p>It matters most while a new tank cycles. Fish waste and leftover food break down
into ammonia; bacteria in the filter turn it into nitrite, then nitrate, which
builds up until a water change removes it. Ammonia and nitrite are toxic to fish,
nitrate much less so. You see the cycle only by testing often and writing it
down.</p>
<p>While a tank cycles, Aquarium shows which stage the readings point to and flags
the usual reasons a cycle stalls, such as alkalinity running out. It calls the
tank cycled after two sessions in a row with ammonia and nitrite clear and
nitrate present, because one clean result can be a misread.</p>
<h2 id="how-do-i-log-a-water-test"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#how-do-i-log-a-water-test">How do I log a water test?</a></h2>
<p>The Log tab sits in the middle of the tab bar and opens on your last tank. Every
parameter that tank tracks is on one page, with your previous values in grey as
a starting point. It takes about twenty seconds, one-handed.</p>
<p>A field you leave blank stays blank. It is never stored as zero, because &quot;I
didn&#39;t test that&quot; and &quot;it was zero&quot; are different facts, and a log that mixes
them up draws trends that never happened. Each parameter has its own timer
for the wait between reagent and reading, and several can run at once.</p>
<figure><img src="https://treamz.me/blog/aquarium-water-test-log-app/quick-log.jpg" alt="Aquarium&#39;s log screen: one page of fields, previous values in grey, a timer beside each" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Logging a test. Blank fields are not recorded as zero</figcaption></figure>
<p>After you save, it works out free ammonia. Your kit reads total ammonia, but the
part that harms fish is un-ionised ammonia (NH3), and its share depends on pH and
temperature: <a href="https://www.epa.gov/caddis/ammonia">the higher the pH, the larger it is</a>,
roughly ten times larger at pH 8 than at pH 7. The app uses the pH and
temperature from the same session and says where the result sits. The same
screen flags readings outside the tank&#39;s target band and parameters moving
faster than they should.</p>
<p>Units follow your kit, per parameter: nitrate as NO3 or NO3-N, alkalinity as
dKH, meq/L or ppm.</p>
<h2 id="can-it-track-water-changes-maintenance-and-livestock"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#can-it-track-water-changes-maintenance-and-livestock">Can it track water changes, maintenance and livestock?</a></h2>
<p>Yes:</p>
<ul>
<li><strong>Water changes.</strong> Enter the volume or the percentage and it fills in the
other. It compares the new water&#39;s temperature and salinity with the tank&#39;s,
since a mismatch can shock the fish.</li>
<li><strong>Reminders</strong> for water changes, testing, filters, media, dosing, feeding and
equipment, across every tank. Late still counts, and Skip is recorded as an
answer. They&#39;re scheduled on the phone, so they fire without signal.</li>
<li><strong>Livestock and equipment:</strong> what went in and when, what left and why, what&#39;s
running and when it was last serviced. Losses are marked on the charts.</li>
<li><strong>Journal and photos,</strong> on the same timeline as the readings.</li>
<li><strong>Care sheet</strong> for a tank sitter and a <strong>help summary</strong> of recent tests to
paste into a forum, both plain text.</li>
</ul>
<h2 id="can-i-track-more-than-one-tank"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#can-i-track-more-than-one-tank">Can I track more than one tank?</a></h2>
<p>Yes, as many as you keep, at no cost. There are seven tank types, from
freshwater community, planted and shrimp to brackish, fish-only saltwater and two
kinds of reef. The type sets which parameters you&#39;re asked to test and their
starting target bands: calcium and magnesium for a reef, hardness and TDS for
shrimp. You can switch parameters off and change bands, but can&#39;t add others.
The Tasks tab shows every tank together.</p>
<h2 id="how-do-i-see-trends-in-my-water-parameters"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#how-do-i-see-trends-in-my-water-parameters">How do I see trends in my water parameters?</a></h2>
<p>The chart icon on a tank shows every parameter on one shared time axis, over 30
days, 90 days, a year or everything. Each keeps its own scale and shaded target
band, and losses are drawn as a line across all of them. In the demo data below,
a heater stuck, the temperature spiked and a coral died the same week.</p>
<p>Tap a parameter for its full history. Any reading can be edited, date and time
included, which is how you&#39;d copy in an old paper log. A parameter not
tested within its interval is flagged as stale.</p>
<figure><img src="https://treamz.me/blog/aquarium-water-test-log-app/parameters.jpg" alt="Aquarium&#39;s parameters screen: temperature, pH, ammonia, nitrite, nitrate and phosphate on one time axis, a loss marked across all" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Every parameter on one time axis. The red line is a loss</figcaption></figure>
<h2 id="where-is-my-aquarium-data-stored"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#where-is-my-aquarium-data-stored">Where is my aquarium data stored?</a></h2>
<ul>
<li><strong>Signed out,</strong> everything lives on your iPhone and nothing is sent anywhere.</li>
<li><strong>Signed in,</strong> with email or Sign in with Apple, your log and photos are also
stored on Supabase so they reach your other devices. Each account can only
read its own rows.</li>
<li>Every save goes to the phone first and is queued for sync, so logging works
with no signal. No analytics, no ads, no tracking.</li>
<li><strong>Settings → Export</strong> gives you everything as CSV and JSON, free, with or
without an account.</li>
<li><strong>Settings → Account → Delete account</strong> removes everything the server holds.
The copy on the phone stays until you delete the app.</li>
</ul>
<h2 id="who-its-for-and-who-it-isnt-for"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#who-its-for-and-who-it-isnt-for">Who it&#39;s for, and who it isn&#39;t for</a></h2>
<p>It&#39;s for anyone who tests their water and wants the numbers in one place, from
a first tank still cycling to a reef or several tanks.</p>
<p>It isn&#39;t for you if:</p>
<ul>
<li><strong>You&#39;re on Android or iPad.</strong> It&#39;s iPhone only, iOS 13 or later, in English.</li>
<li><strong>You want readings to fill themselves in.</strong> Every value is typed: no
test-strip camera, no probes.</li>
<li><strong>You want stocking advice.</strong> Livestock is a list you write, with no species
database or compatibility checks.</li>
<li><strong>You have years of readings elsewhere.</strong> There&#39;s no import; readings can be
backdated one at a time.</li>
<li><strong>You want a diagnosis.</strong> It records, flags and does the ammonia arithmetic. It
doesn&#39;t diagnose sick fish or replace an experienced keeper or a vet.</li>
</ul>
<h2 id="how-to-get-started"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#how-to-get-started">How to get started</a></h2>
<ol>
<li>Install
<a href="https://apps.apple.com/us/app/aquarium-log-tracker/id6804384133">Aquarium - Log Tracker from the App Store</a>.</li>
<li>Tap <strong>Add your first tank</strong>: name, type, volume, and whether it&#39;s cycled.</li>
<li>Tap <strong>Log</strong>, fill in what you tested, save. Leave the rest blank.</li>
<li>Match the units to your kits in <strong>Settings → Units</strong>.</li>
<li>Add reminders on the <strong>Tasks</strong> tab. Sign in only if you want the log on
another device.</li>
</ol>
<p>How it&#39;s built, for developers: <a href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase">Offline-first aquarium log in Flutter with Drift and Supabase</a>.</p>
<p>How it compares with AquaticLog, Aquarimate, Pocket Marine, Nitrify, AquaLens and others: <a href="https://treamz.me/blog/aquarium-log-apps-compared">Aquarium water test log apps for iPhone: 8 compared</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#faq">FAQ</a></h2>
<h3 id="whats-the-best-way-to-keep-track-of-aquarium-water-tests"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#whats-the-best-way-to-keep-track-of-aquarium-water-tests">What&#39;s the best way to keep track of aquarium water tests?</a></h3>
<p>Write down every test with its date, leave blank what you didn&#39;t test rather
than writing zero, and read each parameter over weeks. Aquarium - Log Tracker
is a free iPhone app that does this with charts, test timers and reminders.</p>
<h3 id="is-there-an-app-to-track-ammonia-nitrite-nitrate-and-ph-in-my-tank"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#is-there-an-app-to-track-ammonia-nitrite-nitrate-and-ph-in-my-tank">Is there an app to track ammonia, nitrite, nitrate and pH in my tank?</a></h3>
<p>Yes. Aquarium - Log Tracker for iPhone logs ammonia, nitrite, nitrate, pH and
temperature, plus alkalinity, hardness, phosphate, calcium, magnesium, salinity
and others depending on the tank type, charted on a shared time axis.</p>
<h3 id="how-do-i-know-when-my-aquarium-is-cycled"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#how-do-i-know-when-my-aquarium-is-cycled">How do I know when my aquarium is cycled?</a></h3>
<p>A tank is generally considered cycled when ammonia and nitrite both read zero and
nitrate is present, consistently rather than once. Aquarium - Log Tracker calls a
tank cycled after two test sessions in a row like that.</p>
<h3 id="what-is-free-ammonia"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#what-is-free-ammonia">What is free ammonia?</a></h3>
<p>Many kits report total ammonia: ammonium (NH4+) plus un-ionised ammonia (NH3).
The un-ionised part is the very toxic one, and its share grows as pH rises. Aquarium - Log Tracker calculates it from the ammonia, pH and
temperature in the same test session.</p>
<h3 id="can-i-track-multiple-aquariums-in-one-app"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#can-i-track-multiple-aquariums-in-one-app">Can I track multiple aquariums in one app?</a></h3>
<p>Yes. Aquarium - Log Tracker has no limit on tanks, freshwater or saltwater,
charges nothing for extra ones, and shows maintenance for all of them in one
list.</p>
<h3 id="is-aquarium-log-tracker-free"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#is-aquarium-log-tracker-free">Is Aquarium - Log Tracker free?</a></h3>
<p>Yes. It&#39;s free to download, with no in-app purchases, subscription or ads, and
no account is needed. Exporting everything as CSV or JSON is free too.</p>
<h3 id="does-aquarium-log-tracker-work-on-android-or-ipad"><a class="anchor" href="https://treamz.me/blog/aquarium-water-test-log-app#does-aquarium-log-tracker-work-on-android-or-ipad">Does Aquarium - Log Tracker work on Android or iPad?</a></h3>
<p>No. It&#39;s an iPhone-only app and needs iOS 13 or later.</p>
]]></content:encoded>
    </item>
    <item>
      <title>A quiet time app and study timer that locks distracting apps</title>
      <link>https://treamz.me/blog/quiet-time-study-timer-app</link>
      <guid isPermaLink="true">https://treamz.me/blog/quiet-time-study-timer-app</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>QT is a free iPhone app that starts each study session with a one-minute quiet time: one Bible verse, one line of reflection, then your chosen apps lock.</description>
      <category>iOS</category>
      <category>Screen Time</category>
      <category>Quiet time</category>
      <category>Study timer</category>
      <category>Bible</category>
      <content:encoded><![CDATA[<p>QT is an iPhone app that puts a one-minute quiet time in front of every study
session. It shows one Bible verse and one reflection question, you can write a
line, and then a study timer starts while the apps you chose stay locked through
Apple&#39;s Screen Time. It&#39;s for students, and anyone else who wants to start work
with Scripture rather than a feed; it needs iOS 17 or later and is
<a href="https://apps.apple.com/us/app/qt-bible-study-timer/id6816530118">free to download on the App Store</a>.</p>
<p>QT stands for quiet time, the daily habit of time alone with the Bible; Korean
churches call it 큐티. I made the app for Korean students first, and it&#39;s also in
English, Japanese and Traditional Chinese.</p>
<h2 id="why-does-the-phone-win-the-morning"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#why-does-the-phone-win-the-morning">Why does the phone win the morning?</a></h2>
<p>The plan is usually a verse, then work. What happens instead is a message or a
video that turns into twenty minutes, and by the time you sit down to study, the
quiet part is gone.</p>
<p>QT ties the two habits together: the verse is the first screen, the button under
it starts the study timer, and the timer locks the apps that would have pulled
you away.</p>
<h2 id="how-does-qt-work"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#how-does-qt-work">How does QT work?</a></h2>
<ol>
<li><strong>Read today&#39;s verse.</strong> One verse, in English from the World English Bible,
with a <strong>Read the whole chapter</strong> link that opens the YouVersion Bible app or
bible.com.</li>
<li><strong>Answer one question.</strong> Each day has a single reflection question and room
for a line or two. Writing is optional.</li>
<li><strong>Tap Start studying.</strong> That marks today&#39;s QT as done and adds to your streak.</li>
<li><strong>Pick a subject and a length.</strong> 25, 50 or 90 minutes, or Free, where you set
the time the apps open again, up to six hours ahead.</li>
<li><strong>Study.</strong> The apps you picked stay locked until the timer ends. Then they
unlock on their own and the session is saved under its subject.</li>
</ol>
<figure><img src="https://treamz.me/blog/quiet-time-study-timer-app/today.jpg" alt="QT&#39;s Today screen: Lamentations 3:22-23 from the World English Bible, the question What can I start fresh today?, a one-line answer and a Start studying button" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Today: one verse, one question, one line</figcaption></figure>
<p>The streak forgives one missed day a week; two in a row break it. A reminder
arrives at the time you choose, plus an optional one at 9 pm if the day&#39;s QT
isn&#39;t done. Widgets show today&#39;s verse and your streak on the Home Screen and
Lock Screen.</p>
<h2 id="what-does-the-study-timer-do"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#what-does-the-study-timer-do">What does the study timer do?</a></h2>
<p>It stores when a session started instead of counting seconds, so the time holds
up if the app closes, the phone goes offline or it restarts. While a session
runs, a Live Activity shows it on the Lock Screen and in the Dynamic Island.</p>
<p><strong>History</strong> shows today, this week and totals by subject: English, Math,
Science, History and Other to start with, plus your own. Every session has a
six-hour cap, so a forgotten timer is saved and closed rather than left running.</p>
<p><strong>Reading</strong> has one plan, the New Testament in 90 days: you read in your own
Bible and tick the days off.</p>
<figure><img src="https://treamz.me/blog/quiet-time-study-timer-app/timer.jpg" alt="The running timer: Math, 1:24:38, apps blocked until 6:57 PM, with Pause, Break and Hold to end buttons" width="644" height="1400" loading="lazy" decoding="async"><figcaption>The timer: Pause keeps the lock, Break lifts it</figcaption></figure>
<h2 id="what-gets-blocked-and-how-do-you-get-past-it"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#what-gets-blocked-and-how-do-you-get-past-it">What gets blocked, and how do you get past it?</a></h2>
<p>Only the apps you pick, and only during a session. You choose them in Apple&#39;s
Screen Time picker, as single apps or whole categories. Open one mid-session and
iOS covers it with a screen saying you&#39;re studying and when it unlocks, with one
button: <strong>Go back</strong>. You can&#39;t end the session from there. Inside QT you can:</p>
<ul>
<li><strong>Break</strong>: the clock stops and the apps unlock until you tap <strong>Resume</strong>.
Breaks have no time limit.</li>
<li><strong>Pause</strong>: the clock stops, the apps stay locked.</li>
<li><strong>Hold to end</strong>: hold the button for three seconds to finish.</li>
</ul>
<p>The lock lives in iOS, not in the app, so closing QT doesn&#39;t lift it. At the
planned end, iOS runs a small part of QT that lifts the lock without the app
open.</p>
<p>It&#39;s a speed bump, not a vault: turning off QT&#39;s Screen Time access or deleting
the app removes the lock. It locks apps, not individual websites. The free
version locks one app or category; QT Pro removes the limit.</p>
<h2 id="what-does-qt-know-about-you"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#what-does-qt-know-about-you">What does QT know about you?</a></h2>
<p>Only what stays on your phone; none of it reaches me. The App Store privacy
label reads &quot;Data Not Collected&quot;: no account, no ads, no analytics. Sessions,
reflections, reading progress and settings are stored on the device, and
deleting the app deletes them.
<strong>Settings → Export reflections</strong> gives you your lines as text.</p>
<p>For blocking, Apple hands QT opaque tokens instead of app names. QT can count
them, which is how the free limit works, but can&#39;t tell which apps they are or
how much you use them. Reminders are local notifications and purchases go
through Apple. The only thing that goes online is a chapter you open in
YouVersion or on bible.com, under their own privacy policies.</p>
<p>On a child account in Family Sharing, app locking needs a parent&#39;s approval.</p>
<h2 id="who-its-for-and-who-it-isnt-for"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#who-its-for-and-who-it-isnt-for">Who it&#39;s for, and who it isn&#39;t for</a></h2>
<p>It&#39;s for students who want study to start with a verse rather than a feed, and
for anyone who works in timed blocks and keeps losing them to the phone.</p>
<p>It isn&#39;t for you if:</p>
<ul>
<li><strong>You&#39;re on Android.</strong> QT is built for iPhone, on iOS 17 or later.</li>
<li><strong>You want a Bible study app.</strong> QT shows one verse and one question, not
commentary, audio or a full reader.</li>
<li><strong>You want apps locked until you&#39;ve read the Bible.</strong> The lock belongs to the
study timer; outside a session nothing is locked.</li>
<li><strong>You want a lock you can&#39;t break.</strong> Break and Hold to end are always there.</li>
</ul>
<h2 id="how-to-get-started"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#how-to-get-started">How to get started</a></h2>
<ol>
<li>Install
<a href="https://apps.apple.com/us/app/qt-bible-study-timer/id6816530118">QT - Bible &amp; Study Timer from the App Store</a>.</li>
<li>Go through the intro screens: allow Screen Time access when iOS asks, then
pick a reminder time and allow notifications.</li>
<li>In <strong>Settings → Apps to block</strong>, choose what to lock. Free covers one app or
category; Pro, any number.</li>
<li>On <strong>Today</strong>, read the verse, write a line if you like, tap <strong>Start
studying</strong>, choose a subject and a length, and tap <strong>Start</strong>.</li>
</ol>
<p>QT is an independent app, not affiliated with Apple or YouVersion.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#faq">FAQ</a></h2>
<h3 id="what-is-a-quiet-time-app"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#what-is-a-quiet-time-app">What is a quiet time app?</a></h3>
<p>A quiet time app supports the daily habit of time alone with the Bible, often
called QT or a devotional: a short passage, a question to think about and a
place to write. QT on iPhone keeps it to about a minute, one verse, one question
and one line, then starts a study timer.</p>
<h3 id="is-there-a-study-timer-that-blocks-distracting-apps-on-iphone"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#is-there-a-study-timer-that-blocks-distracting-apps-on-iphone">Is there a study timer that blocks distracting apps on iPhone?</a></h3>
<p>Yes. Timers built on Apple&#39;s Screen Time can lock chosen apps while a session
runs. QT does this for 25, 50 or 90 minutes or until a time you set, then
unlocks them. It&#39;s free, with one locked app in the free version.</p>
<h3 id="is-there-an-app-that-locks-my-phone-until-i-read-the-bible"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#is-there-an-app-that-locks-my-phone-until-i-read-the-bible">Is there an app that locks my phone until I read the Bible?</a></h3>
<p>QT comes close but works the other way round. You read one verse, then start a
study session, and the session is what locks the apps you choose. Reading
doesn&#39;t unlock anything, and outside a session nothing is locked.</p>
<h3 id="is-qt-free"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#is-qt-free">Is QT free?</a></h3>
<p>Yes. The daily QT, the timer, locking one app, the reading plan and the widgets
are free, and there are no ads. QT Pro removes the limit on how many apps can be
locked; in the US it costs $4.99 a month, $29.99 a year or $89.99 once.</p>
<h3 id="which-bible-translation-does-qt-use"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#which-bible-translation-does-qt-use">Which Bible translation does QT use?</a></h3>
<p>In English, QT uses the World English Bible, a public-domain translation that
says Yahweh where many Bibles print &quot;the LORD&quot;. The Korean edition uses 개역한글,
the Japanese 口語訳 and the Traditional Chinese 和合本. The whole chapter opens in
the YouVersion Bible app or on bible.com.</p>
<h3 id="can-qt-see-which-apps-i-block-or-what-i-write"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#can-qt-see-which-apps-i-block-or-what-i-write">Can QT see which apps I block or what I write?</a></h3>
<p>No. Apple&#39;s Screen Time gives QT opaque tokens, not app names, and your
reflections and study history are stored only on your phone. There&#39;s no
account, and the App Store privacy label reads &quot;Data Not Collected&quot;.</p>
<h3 id="what-happens-if-qt-closes-during-a-study-session"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#what-happens-if-qt-closes-during-a-study-session">What happens if QT closes during a study session?</a></h3>
<p>The session continues. QT stores when it started rather than counting seconds,
so the time is right when you reopen it, and iOS lifts the lock at the planned
end without the app open.</p>
<h3 id="does-qt-work-on-android-or-ipad"><a class="anchor" href="https://treamz.me/blog/quiet-time-study-timer-app#does-qt-work-on-android-or-ipad">Does QT work on Android or iPad?</a></h3>
<p>QT is built for iPhone and needs iOS 17 or later. There is no Android version
and no iPad version.</p>
]]></content:encoded>
    </item>
    <item>
      <title>A quit weed app with a day-by-day withdrawal timeline</title>
      <link>https://treamz.me/blog/quit-weed-app</link>
      <guid isPermaLink="true">https://treamz.me/blog/quit-weed-app</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>Reset is an iPhone app for quitting weed or taking a tolerance break: a day-by-day withdrawal timeline, milestones and money saved, with no sign-up.</description>
      <category>Quitting weed</category>
      <category>Tolerance break</category>
      <category>Cannabis withdrawal</category>
      <category>iOS</category>
      <category>Health</category>
      <content:encoded><![CDATA[<p>If you want an app to help you quit weed or take a tolerance break, this is the one I built: <a href="https://apps.apple.com/us/app/reset-quit-weed-t-break/id6783851544">Reset: Quit Weed &amp; T-Break</a>, for iPhone, with a two-week free trial. It counts the time since you last used, shows a day-by-day cannabis withdrawal timeline for sleep, dreams, appetite, mood, anxiety and cravings, and tracks milestones and money not spent. You don&#39;t need to sign up or give an email, and your data is never sold.</p>
<h2 id="what-the-first-weeks-without-cannabis-are-usually-like"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#what-the-first-weeks-without-cannabis-are-usually-like">What the first weeks without cannabis are usually like</a></h2>
<p>Not everyone who stops gets withdrawal. A 2020 meta-analysis of 47 studies put it at about 47% of people with regular or dependent use, and higher with daily use (<a href="https://jamanetwork.com/journals/jamanetworkopen/fullarticle/2764234">Bahji et al., JAMA Network Open</a>). When it happens, the pattern is consistent:</p>
<ul>
<li><strong>Start:</strong> typically 24 to 48 hours after the last use (<a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC9110555/">Connor et al., Addiction, 2022</a>).</li>
<li><strong>Peak:</strong> most symptoms peak between days 2 and 6 (same review).</li>
<li><strong>Length:</strong> usually up to about three weeks (<a href="https://www.dovepress.com/the-cannabis-withdrawal-syndrome-current-insights-peer-reviewed-fulltext-article-SAR">Bonnet and Preuss, 2017</a>). In heavy users some symptoms last three weeks or more, and sleep problems may continue for several weeks or longer (Connor et al.).</li>
<li><strong>Symptoms:</strong> most often anxiety, irritability, anger, disturbed sleep and dreams, low mood and loss of appetite. <a href="https://nida.nih.gov/research-topics/cannabis-marijuana">NIDA</a> also lists restlessness, headaches, sweating, abdominal pain and tremor.</li>
</ul>
<h2 id="how-reset-tracks-a-quit-or-a-t-break"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#how-reset-tracks-a-quit-or-a-t-break">How Reset tracks a quit or a T-break</a></h2>
<p>You pick a goal (quit, tolerance break or cut back) and tell the app when you last used. The count starts then, not when you installed it. It counts in hours for the first 72 hours, then in days.</p>
<p>Every day has a plain-language &quot;what to expect&quot; and one practical tip. The daily check-in asks you to rate mood, sleep, cravings, dreams and anxiety, then shows how your day compares with a typical day at the same point for someone like you. A rough day 4 is often right on schedule.</p>
<p>For bad moments there is Craving SOS: your own reasons in your own words, guided 4-2-6 breathing and a 10-minute urge-surf timer. If you slip, you log it and the count restarts. Your history and longest streak stay saved.</p>
<figure><img src="https://treamz.me/blog/quit-weed-app/today.jpg" alt="Reset&#39;s Today screen: 6 days clean, $60 saved, 17% tolerance back, and a card about vivid dreams" width="647" height="1400" loading="lazy" decoding="async"><figcaption>The Today screen: count, money not spent, today&#39;s card and a tip</figcaption></figure>
<h2 id="the-withdrawal-timeline-in-the-app"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#the-withdrawal-timeline-in-the-app">The withdrawal timeline in the app</a></h2>
<p>The timeline is the part I spent the most time on. It draws six tracks (sleep, vivid dreams, appetite, mood, anxiety and cravings) across your first weeks, based on published research, with the sources listed in the app. It is tuned to how you used: how often, and whether it was flower, vape/concentrate or edibles. In the current build a moderate-use profile covers 21 days, a daily-flower profile 30 and a concentrate-heavy profile 38.</p>
<p>The day cards follow those ranges:</p>
<ol>
<li><strong>Day 1:</strong> often easier than expected, though cravings and irritability can start.</li>
<li><strong>Days 2–3:</strong> sleep fragments, appetite drops, anxiety climbs.</li>
<li><strong>Days 3–5:</strong> usually the hardest window, with vivid dreams and light sleep.</li>
<li><strong>Weeks 1–2:</strong> the sharpest symptoms ease; dreams often peak, then fade.</li>
<li><strong>Weeks 3–4:</strong> for most people mood evens out, and sleep and appetite come back.</li>
<li><strong>Day 30 on:</strong> heavy, long-term users may still notice symptoms now and then.</li>
</ol>
<p>It is a typical curve, not a prediction. If your day 5 beats the chart, good.</p>
<figure><img src="https://treamz.me/blog/quit-weed-app/timeline.jpg" alt="Reset&#39;s timeline: symptom curves over 30 days above day-by-day cards" width="647" height="1400" loading="lazy" decoding="async"><figcaption>The timeline for a daily-flower profile, 30-day view</figcaption></figure>
<h2 id="milestones-and-money-saved"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#milestones-and-money-saved">Milestones and money saved</a></h2>
<p>A countdown runs to the next milestone: 24 hours, 72 hours, a week, 30 days and on. Since version 1.3.0 (3 November 2026) milestones up to a year earn badges, as do urges ridden out, check-ins and coming back after a slip. Slips never take a badge away.</p>
<p>Money not spent is shown in your own currency, based on what you say you usually spend a day. Every Monday a short recap compares last week with the week before, and the check-in streak allows one missed day a week.</p>
<h2 id="what-happens-to-your-data"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#what-happens-to-your-data">What happens to your data</a></h2>
<p>I&#39;d rather be exact here than impressive. Per the <a href="https://reset-web.pages.dev/privacy">privacy policy</a> and the listing:</p>
<ul>
<li>No account or email to start. The app uses an anonymous identifier.</li>
<li>It stores your goal, usage profile (frequency, form, years, spend), start date and daily check-ins.</li>
<li>Data is saved on your phone first, so the app works offline, then synced in the background to my backend on Supabase over HTTPS, where each user can only access their own rows. Private, but not on-device only.</li>
<li>No ads, no third-party tracking SDKs, nothing sold.</li>
<li>You can export your data, and delete everything from inside the app, on the phone and on the server.</li>
<li>Reminders and the home-screen widget never mention weed, cannabis or THC.</li>
</ul>
<h2 id="quitting-or-a-tolerance-break"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#quitting-or-a-tolerance-break">Quitting or a tolerance break</a></h2>
<p>Both are legitimate goals. Tolerance is linked to regular use desensitising and reducing the brain&#39;s CB1 cannabinoid receptors. That starts to reverse within the first two days without cannabis, and the receptors return to normal after roughly four weeks (<a href="https://www.dovepress.com/the-cannabis-withdrawal-syndrome-current-insights-peer-reviewed-fulltext-article-SAR">Bonnet and Preuss</a>).</p>
<p>T-break mode gives you a 48-hour, 7-day or 21-day countdown and a ring that estimates your tolerance recovering on that model. It is an illustration, not a measurement. A 48-hour break ends about where withdrawal, if you get it, usually starts to peak. Afterwards you can extend it, and a short harm-reduction guide explains why your tolerance is likely lower and how to stay safe.</p>
<p>Not ready for either? Cut-back mode sets a weekly goal and logs sessions in two taps, with no days-clean counter.</p>
<h2 id="who-its-for-and-who-it-isnt"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#who-its-for-and-who-it-isnt">Who it&#39;s for, and who it isn&#39;t</a></h2>
<p>Reset is for adults (18+) who want to stop or pause and know what&#39;s coming. The app is available in English, Spanish, Portuguese (Brazil), German and Russian.</p>
<p>It is education, not medical advice, and not a treatment for cannabis use disorder. Talk to a doctor if you&#39;ve used heavily every day for a long time, use other substances, have mental-health concerns, or if withdrawal feels severe. The first-line help is supportive counselling and psychoeducation; no medication is approved specifically for it (<a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC9110555/">Connor et al.</a>). Talking to someone is the real treatment, not a fallback. In the US, <a href="https://www.samhsa.gov/find-help/helplines/national-helpline">SAMHSA&#39;s National Helpline</a> is 1-800-662-4357: free, confidential, 24/7, for treatment referral and information. In a crisis, call or text 988.</p>
<h2 id="how-to-get-started"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#how-to-get-started">How to get started</a></h2>
<ol>
<li>Download <a href="https://apps.apple.com/us/app/reset-quit-weed-t-break/id6783851544">Reset from the App Store</a>. It needs iOS 15 or later and also runs on iPad.</li>
<li>Choose quit, tolerance break or cut back.</li>
<li>Enter when you last used, how often, and in what form.</li>
<li>Start the two-week free trial on the yearly plan, or subscribe monthly. The trial&#39;s end date and the price after it are shown before you start, and you get a reminder two days before it ends.</li>
<li>Add your usual daily spend if you want the money counter.</li>
<li>Write your reasons into Craving SOS while you still feel sure of them.</li>
<li>Check in once a day.</li>
</ol>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#faq">FAQ</a></h2>
<h3 id="how-long-does-weed-withdrawal-last"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#how-long-does-weed-withdrawal-last">How long does weed withdrawal last?</a></h3>
<p>For people who get it, cannabis withdrawal usually starts 24 to 48 hours after the last use, peaks between days 2 and 6, and lasts up to about three weeks (<a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC9110555/">Connor et al., 2022</a>; <a href="https://www.dovepress.com/the-cannabis-withdrawal-syndrome-current-insights-peer-reviewed-fulltext-article-SAR">Bonnet and Preuss, 2017</a>). Heavy users can have some symptoms for three weeks or more, and sleep problems may last several weeks.</p>
<h3 id="what-should-i-expect-when-i-stop-smoking-weed"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#what-should-i-expect-when-i-stop-smoking-weed">What should I expect when I stop smoking weed?</a></h3>
<p>The most common withdrawal symptoms are anxiety, irritability, anger, trouble sleeping, strange dreams, low mood and loss of appetite; some people also get restlessness, headaches, sweating, stomach pain or tremor (<a href="https://nida.nih.gov/research-topics/cannabis-marijuana">NIDA</a>).</p>
<h3 id="is-there-an-app-to-help-me-quit-weed-or-take-a-t-break"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#is-there-an-app-to-help-me-quit-weed-or-take-a-t-break">Is there an app to help me quit weed or take a T-break?</a></h3>
<p>Reset is an iPhone app for quitting weed or taking a tolerance break, with 48-hour, 7-day and 21-day T-break countdowns, a day-by-day withdrawal timeline tuned to how you used, check-ins, milestones and money saved. It is on the <a href="https://apps.apple.com/us/app/reset-quit-weed-t-break/id6783851544">App Store</a>.</p>
<h3 id="how-long-should-a-tolerance-break-be"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#how-long-should-a-tolerance-break-be">How long should a tolerance break be?</a></h3>
<p>There is no single right length. The CB1 receptor changes behind tolerance start to reverse within two days of stopping and return to normal after roughly four weeks (<a href="https://www.dovepress.com/the-cannabis-withdrawal-syndrome-current-insights-peer-reviewed-fulltext-article-SAR">Bonnet and Preuss, 2017</a>), so a longer break gives them more time.</p>
<h3 id="why-am-i-having-vivid-dreams-after-quitting-weed"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#why-am-i-having-vivid-dreams-after-quitting-weed">Why am I having vivid dreams after quitting weed?</a></h3>
<p>Disturbed sleep and strange or vivid dreams are among the most common cannabis withdrawal symptoms, and the dreams often start later than the rest (<a href="https://www.dovepress.com/the-cannabis-withdrawal-syndrome-current-insights-peer-reviewed-fulltext-article-SAR">Bonnet and Preuss, 2017</a>). Sleep problems may continue for several weeks or longer (<a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC9110555/">Connor et al., 2022</a>).</p>
<h3 id="is-weed-withdrawal-dangerous-and-when-should-i-see-a-doctor"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#is-weed-withdrawal-dangerous-and-when-should-i-see-a-doctor">Is weed withdrawal dangerous, and when should I see a doctor?</a></h3>
<p>Cannabis withdrawal doesn&#39;t carry a high risk of severe outcomes, but it is harder with mental-health conditions or other substance use (<a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC9110555/">Connor et al., 2022</a>). See a doctor if symptoms feel severe, you use other substances, or you have mental-health concerns. In the US, SAMHSA&#39;s National Helpline, 1-800-662-4357, is free, confidential and open 24/7.</p>
<h3 id="is-reset-free-and-does-it-need-an-account"><a class="anchor" href="https://treamz.me/blog/quit-weed-app#is-reset-free-and-does-it-need-an-account">Is Reset free, and does it need an account?</a></h3>
<p>Reset is free to download and needs a subscription after a two-week free trial on the yearly plan: $29.99 a year or $3.99 a month on the US App Store at the time of writing. Craving SOS always opens without a subscription, so nobody hits a paywall in a hard moment. No account or email is needed: data is saved on your phone, synced in the background under an anonymous identifier, and never sold.</p>
]]></content:encoded>
    </item>
    <item>
      <title>App blocker you unlock with push-ups: Pull to Refresh</title>
      <link>https://treamz.me/blog/app-blocker-unlock-with-exercise</link>
      <guid isPermaLink="true">https://treamz.me/blog/app-blocker-unlock-with-exercise</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>Pull to Refresh is a free iPhone app that locks apps you choose when your allowance runs out. Push-ups, squats or pull-ups counted by the camera buy time back.</description>
      <category>iOS</category>
      <category>Screen Time</category>
      <category>App Blocker</category>
      <category>Fitness</category>
      <category>Privacy</category>
      <content:encoded><![CDATA[<p>Pull to Refresh is an app blocker for iPhone that makes you exercise to get your
apps back. You pick the apps you lose hours to, such as Instagram or TikTok, and
give them an allowance; when it runs out they lock until you do push-ups, squats,
sit-ups, pull-ups or jumping jacks in front of the camera. It&#39;s for people who have
learned to tap straight past the built-in Screen Time limit, it needs iOS 17 or
later, and it&#39;s
<a href="https://apps.apple.com/us/app/pull-to-refresh-screen-time/id6809189842">free to download on the App Store</a>.</p>
<p>I build it on my own. Here&#39;s how it works and where its limits are.</p>
<h2 id="why-the-built-in-screen-time-limit-is-easy-to-ignore"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#why-the-built-in-screen-time-limit-is-easy-to-ignore">Why the built-in Screen Time limit is easy to ignore</a></h2>
<p>iOS already has App Limits, under Settings → Screen Time. When one runs out you
get an Ignore Limit button: one more minute, a reminder in 15 minutes, or the
rest of the day. You can add a Screen Time passcode, but if you set it yourself,
you know it.</p>
<p>The way past the limit costs one tap, and one tap is what the habit is made of.
Pull to Refresh keeps the lock and changes the price to a set of exercise. That
takes long enough to notice what you were about to do.</p>
<h2 id="how-pull-to-refresh-works"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#how-pull-to-refresh-works">How Pull to Refresh works</a></h2>
<ol>
<li><strong>Choose the apps.</strong> iOS asks for Screen Time access on first launch, then you
pick apps or whole categories in Apple&#39;s own picker.</li>
<li><strong>Set the allowance.</strong> Always locked, minutes per day (resets at midnight),
minutes per hour, or a weekly schedule.</li>
<li><strong>Run out, and they lock.</strong> iOS puts a shield over those apps. A daily
allowance warns you five minutes before it ends.</li>
<li><strong>Do a set.</strong> Tap <strong>Do the reps</strong> on the shield. On iOS 26.5 and later that
opens the workout screen directly; on older versions you tap a notification.</li>
<li><strong>Spend what you earned</strong> on whichever locked app you were reaching for.</li>
</ol>
<p>At the base rate a clean rep earns 10 seconds for a jumping jack, 20 for a squat
or sit-up, 30 for a push-up and 90 for a pull-up. The smallest unlock is one
minute. Lots of steps and a streak of daily sets raise the rate, capped at
double. With time already banked, the shield offers <strong>Spend</strong> and the app opens
on the spot.</p>
<p>Earned time runs only while you actually use the locked apps. Put the phone down
mid-scroll and the minutes wait: at least half an hour, longer for bigger unlocks,
up to 22 hours.</p>
<figure><img src="https://treamz.me/blog/app-blocker-unlock-with-exercise/today.jpg" alt="The Today screen: 14 minutes banked and two rules, one daily and one hourly" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Today: banked time, and the rules it can be spent on</figcaption></figure>
<h2 id="how-the-camera-counts-reps-and-what-happens-to-the-video"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#how-the-camera-counts-reps-and-what-happens-to-the-video">How the camera counts reps, and what happens to the video</a></h2>
<p>Counting runs on the phone, with Apple&#39;s Vision framework. Each frame goes into
body pose detection, which returns joint positions; the app decides whether a rep
happened and drops the frame. No video or image is recorded, saved or uploaded.
There&#39;s no account and no server, and the App Store privacy label reads &quot;Data Not
Collected&quot;.</p>
<p>A rep only counts on a full movement. For a push-up your elbows bend past 100°
and straighten beyond 150°; for a squat your knees go past 110° and back beyond
160°; a pull-up counts when your chin clears your hands. Reps arriving too fast
don&#39;t count, so shaking the phone pays nothing. When a rep doesn&#39;t count, the app
says why.</p>
<p>For blocking, iOS hands the app opaque tokens instead of app names, so it can&#39;t
tell which apps you picked or see inside them. Data leaves only through Apple:
reps to Game Center and a typed name with your numbers to an iCloud leaderboard,
each only after you opt in, and a copy of your history to your own iCloud.</p>
<p>It&#39;s still a camera, and a determined person can fool it.</p>
<h2 id="strict-mode-per-hour-limits-and-schedules"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#strict-mode-per-hour-limits-and-schedules">Strict mode, per-hour limits and schedules</a></h2>
<p>These are in Pro, which makes the lock harder, never softer:</p>
<ul>
<li><strong>Strict mode.</strong> A rule you can&#39;t edit, switch off or delete on impulse.
Changing it starts a 15-minute cooling-off period first.</li>
<li><strong>Minutes per hour.</strong> Five minutes an hour, say, refilled at the top of each
hour.</li>
<li><strong>Schedules.</strong> Work chat shut from six until morning, or the feed locked only
during work hours. Reps still buy a way in.</li>
<li><strong>Unlimited rules</strong>, each with its own limit.</li>
</ul>
<p>Money never buys screen time here. If Pro lapses, your existing rules keep
running.</p>
<h2 id="saving-sets-to-health"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#saving-sets-to-health">Saving sets to Health</a></h2>
<p>With Health switched on, each finished set is saved to the Health app as a
workout, with an estimate of active energy and the rep count, so it counts toward
your Move ring and shows up in Fitness. The energy is estimated from duration and
body mass; no heart rate is involved.</p>
<h2 id="who-its-for-and-who-it-isnt-for"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#who-its-for-and-who-it-isnt-for">Who it&#39;s for, and who it isn&#39;t for</a></h2>
<p>It&#39;s for you if you open the same app by reflex, sit at a desk all day, or want a
blocker that doesn&#39;t send your usage anywhere.</p>
<p>It isn&#39;t for you if:</p>
<ul>
<li><strong>You&#39;re on Android or iPad.</strong> It&#39;s only for iPhone, on iOS 17 or later.</li>
<li><strong>You want to manage a child&#39;s phone.</strong> It locks the owner&#39;s own device.</li>
<li><strong>You want a lock you can&#39;t break.</strong> Deleting the app removes every lock. A
Screen Time passcode set by someone else makes it stick better.</li>
<li><strong>You need it to the second.</strong> iOS reports usage in batches, so an allowance
can overrun by a minute or two.</li>
<li><strong>Reps aren&#39;t possible for you.</strong> Squats want your whole body in frame, about
2 m away. An optional easy mode lets 1,000 steps buy three minutes, up to
thirty a day.</li>
</ul>
<p>It&#39;s not a training program either. If exercise is a health question for you,
ask a doctor.</p>
<h2 id="how-to-get-started"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#how-to-get-started">How to get started</a></h2>
<ol>
<li>Install
<a href="https://apps.apple.com/us/app/pull-to-refresh-screen-time/id6809189842">Pull to Refresh from the App Store</a>.</li>
<li>Grant Screen Time access when iOS asks, then notifications and, if you like,
Health.</li>
<li>Pick the apps and an allowance. The default is 30 minutes a day; Always locked
shields them at once.</li>
<li>When an app locks, tap <strong>Do the reps</strong>, pick an exercise, and allow the camera.</li>
<li>Prop the phone up so it sees you, do the set, tap <strong>Finish set</strong>, and go back
to the app.</li>
</ol>
<p>Pull to Refresh is an independent app, not affiliated with Apple, Instagram or
TikTok.</p>
<p>How it&#39;s built, for developers: <a href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time">Counting reps with Vision and locking apps with Screen Time</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#faq">FAQ</a></h2>
<h3 id="how-can-i-earn-screen-time-by-exercising"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#how-can-i-earn-screen-time-by-exercising">How can I earn screen time by exercising?</a></h3>
<p>Use an app blocker that turns exercise into minutes: the apps you want to limit
get an allowance, and once it&#39;s gone, a set of exercise buys more. Pull to
Refresh does this on iPhone, counting reps with the camera, on the device. It&#39;s
free to download on the App Store.</p>
<h3 id="is-there-an-app-that-blocks-instagram-or-tiktok-until-i-work-out"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#is-there-an-app-that-blocks-instagram-or-tiktok-until-i-work-out">Is there an app that blocks Instagram or TikTok until I work out?</a></h3>
<p>Yes. Pull to Refresh locks the apps you choose, Instagram and TikTok included,
when your allowance runs out, and unlocks them for push-ups, squats, sit-ups,
pull-ups or jumping jacks counted by the iPhone&#39;s camera.</p>
<h3 id="how-much-screen-time-does-one-push-up-earn-in-pull-to-refresh"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#how-much-screen-time-does-one-push-up-earn-in-pull-to-refresh">How much screen time does one push-up earn in Pull to Refresh?</a></h3>
<p>At the base rate a push-up earns 30 seconds, a squat or sit-up 20, a jumping jack
10 and a pull-up 90. Lots of steps and a streak of daily sets can raise the rate
up to double. The smallest unlock is one minute.</p>
<h3 id="does-pull-to-refresh-record-or-upload-video"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#does-pull-to-refresh-record-or-upload-video">Does Pull to Refresh record or upload video?</a></h3>
<p>No. Frames go into Apple&#39;s on-device Vision framework, which returns joint
positions, and each frame is discarded once Pull to Refresh has checked it for a
rep. Nothing is recorded or uploaded, and there is no account.</p>
<h3 id="is-pull-to-refresh-hard-to-bypass"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#is-pull-to-refresh-hard-to-bypass">Is Pull to Refresh hard to bypass?</a></h3>
<p>Harder than the built-in limit, not impossible. In strict mode, part of Pull to
Refresh Pro, a rule can&#39;t be changed until a 15-minute cooling-off period has
run. Deleting the app removes every lock, so a Screen Time passcode set by
someone else is what makes it stick.</p>
<h3 id="is-pull-to-refresh-free"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#is-pull-to-refresh-free">Is Pull to Refresh free?</a></h3>
<p>Yes. The free version covers one group of apps on a daily allowance, every
exercise, Health and the leaderboards. Pro, a subscription or one-off purchase,
adds strict mode, per-hour limits, schedules, unlimited rules and full history.
It never buys screen time.</p>
<h3 id="does-pull-to-refresh-work-on-android-or-ipad"><a class="anchor" href="https://treamz.me/blog/app-blocker-unlock-with-exercise#does-pull-to-refresh-work-on-android-or-ipad">Does Pull to Refresh work on Android or iPad?</a></h3>
<p>No. Pull to Refresh is only for iPhone, on iOS 17 or later, because it is built
on Apple&#39;s Screen Time frameworks.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Count calories from a photo on iPhone: how accurate is it?</title>
      <link>https://treamz.me/blog/count-calories-from-a-photo</link>
      <guid isPermaLink="true">https://treamz.me/blog/count-calories-from-a-photo</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>MealMate is an AI calorie counter for iPhone, free to download: photograph a meal and it estimates calories, carbs, protein and fat for each food on the plate.</description>
      <category>Calorie counting</category>
      <category>Nutrition</category>
      <category>Food log</category>
      <category>iOS</category>
      <category>AI</category>
      <content:encoded><![CDATA[<p>MealMate is an iPhone app that counts calories from a photo. You take a picture of your plate,
and it lists each food it sees with an estimated weight, calories, carbs, protein and fat, then
adds it to your daily log. It&#39;s for people who want a rough, honest picture of what they eat
without typing every ingredient into a form, it needs iOS 26 or later, and it&#39;s free to download
on the <a href="https://apps.apple.com/us/app/mealmate-ai-calorie-counter/id6680196823">App Store</a>.</p>
<p>I&#39;m the developer, and this is how it works.</p>
<h2 id="why-do-people-give-up-on-calorie-counting"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#why-do-people-give-up-on-calorie-counting">Why do people give up on calorie counting?</a></h2>
<p>The arithmetic is easy. The logging is what wears people down. A homemade stir-fry is six or
seven ingredients, each one weighed, searched for and picked from a list of near-identical
database entries. Three meals a day of that starts to feel like a second job.</p>
<p>A photo trades some precision for far less effort. That&#39;s the whole bet.</p>
<h2 id="how-do-you-log-a-meal-from-a-photo"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#how-do-you-log-a-meal-from-a-photo">How do you log a meal from a photo?</a></h2>
<ol>
<li>Tap the scan button in the middle of the tab bar and photograph your plate, or pick a photo
from your library. The analysis starts on its own.</li>
<li>You get a list: each food with a name, an estimated weight in grams, calories, and carbs,
protein and fat.</li>
<li>If the result looks off, run the analysis again or correct the portion.</li>
<li>Tap <strong>Add to Daily</strong>. The meal goes into whichever day you have open, so yesterday&#39;s dinner
works too.</li>
</ol>
<p>On iPhones with a LiDAR scanner (the Pro models), the camera also measures the distance to the
plate and estimates its diameter, which goes along with the photo as a size reference. That
arrived in version 2.1.6.</p>
<p>When a photo won&#39;t do, the <strong>+</strong> button on the Daily screen opens manual entry: search a food
database, or type a name and calories, with weight and macros optional.</p>
<h2 id="how-accurate-is-it"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#how-accurate-is-it">How accurate is it?</a></h2>
<p>Any photo calorie counter is guessing two things: what the food is, and how much of it there is.
The amount is the hard part, because one picture shows no depth and nothing under the top layer.
And some calories are invisible: oil in the pan, butter in the mash, sugar in a sauce or a drink.</p>
<p>MealMate&#39;s estimates come from Google&#39;s Gemini model, which names the foods in the photo and
estimates their portions and nutrition.</p>
<p>To correct an estimate, tap the grams on any item and type the real weight; calories and macros
scale in proportion. Editing the weight, name or calories of a photo result is part of the
optional subscription. Without it, swipe to delete the entry and add it by hand.</p>
<p>For packaged food, the label beats any photo. For the rest, weighing a portion now and then
calibrates your eye.</p>
<p>So treat a photo estimate as a starting point. It shows the shape of a day and the trend over
weeks, not numbers you can trust to within a few percent. If you count carbs to dose insulin or
follow a diet for a medical condition, use the numbers your doctor or dietitian gives you.</p>
<h2 id="how-are-the-daily-calorie-and-macro-targets-set"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#how-are-the-daily-calorie-and-macro-targets-set">How are the daily calorie and macro targets set?</a></h2>
<p>On first launch the app asks for your sex, age, height, current and goal weight, goal, activity
level, how fast you want to change, and a diet type. The calorie target is the Mifflin-St Jeor
estimate of resting energy times an activity factor, from 1.2 (sedentary) to 1.725 (very active).
To lose or gain weight it subtracts or adds 250, 500, 750 or 1,000 kcal a day, depending on the
speed you picked, and it never goes below 1,200 kcal for women or 1,500 kcal for men.</p>
<p>Macros follow the diet type: balanced is 50% carbs, 20% protein and 30% fat, keto is 5/20/75, and
there are low carb, high protein, low fat and vegetarian splits too. The Daily screen shows the
calories left and grams of carbs, protein and fat against their targets. Weight goes in the
Profile tab, in kilograms or pounds, as a chart against your goal weight.</p>
<h2 id="where-do-my-food-photos-go"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#where-do-my-food-photos-go">Where do my food photos go?</a></h2>
<p>To analyse a meal, the photo goes to MealMate&#39;s server, which passes it to Google&#39;s Gemini model
and sends back the result, so photo logging needs an internet connection.</p>
<p>You need an account (email, Google or Apple), which stores your food log, profile and weight
history. You can delete your account in Settings. The app also collects analytics and crash
reports.</p>
<h2 id="who-is-it-for-and-who-isnt-it-for"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#who-is-it-for-and-who-isnt-it-for">Who is it for, and who isn&#39;t it for?</a></h2>
<p>It&#39;s for people who stopped logging because it took too long, and for anyone who mostly eats
home-cooked or restaurant food, where there&#39;s no barcode anyway.</p>
<p>It&#39;s the wrong tool if you need exact numbers for a medical diet or a competition cut. There&#39;s no
barcode scanner, so packaged food means typing labels in. It doesn&#39;t sync with Apple Health, and
it&#39;s iPhone only. And if counting makes your relationship with food worse, skip it.</p>
<h2 id="how-to-get-started"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#how-to-get-started">How to get started</a></h2>
<ol>
<li>Install MealMate from the
<a href="https://apps.apple.com/us/app/mealmate-ai-calorie-counter/id6680196823">App Store</a> on an iPhone
with iOS 26 or later.</li>
<li>Sign up with email, Google or Apple.</li>
<li>Answer the setup questions to get your calorie and macro targets.</li>
<li>Tap the scan button and photograph your next meal.</li>
<li>Check the foods and portions, then tap <strong>Add to Daily</strong>.</li>
</ol>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#faq">FAQ</a></h2>
<h3 id="is-there-an-app-that-counts-calories-from-a-photo"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#is-there-an-app-that-counts-calories-from-a-photo">Is there an app that counts calories from a photo?</a></h3>
<p>Yes. Photo calorie counters use an AI model to identify the food in a picture and estimate the
portion. MealMate is one: a free-to-download iPhone app that lists each food on the plate with
estimated grams, calories, carbs, protein and fat, and adds it to a daily log.</p>
<h3 id="how-accurate-are-photo-calorie-counters"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#how-accurate-are-photo-calorie-counters">How accurate are photo calorie counters?</a></h3>
<p>They give estimates, not measurements. The biggest errors come from portion size, which is hard
to judge from one picture, and from calories a camera can&#39;t see, such as cooking oil and sugar in
sauces or drinks. They&#39;re useful for daily totals and trends, especially if you correct the
portion when you know it.</p>
<h3 id="how-do-i-log-calories-by-taking-a-picture-of-my-food"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#how-do-i-log-calories-by-taking-a-picture-of-my-food">How do I log calories by taking a picture of my food?</a></h3>
<p>In MealMate, tap the scan button, photograph your plate or pick a photo from your library, check
the foods and grams it found, and tap Add to Daily.</p>
<h3 id="does-mealmate-upload-my-food-photos"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#does-mealmate-upload-my-food-photos">Does MealMate upload my food photos?</a></h3>
<p>Yes, for the analysis: the photo goes to MealMate&#39;s server, which uses Google&#39;s Gemini model to
identify the foods and estimate portions, so photo logging needs an internet connection.</p>
<h3 id="is-mealmate-free"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#is-mealmate-free">Is MealMate free?</a></h3>
<p>MealMate is free to download, and photo logging and manual entry work without paying. An optional
subscription adds editing of photo results, the vitamin and mineral breakdown, and custom
targets. On the US App Store it&#39;s listed at $2.99 a month or $17.99 a year as of November 2026.</p>
<h3 id="whats-the-easiest-way-to-track-calories-and-macros"><a class="anchor" href="https://treamz.me/blog/count-calories-from-a-photo#whats-the-easiest-way-to-track-calories-and-macros">What&#39;s the easiest way to track calories and macros?</a></h3>
<p>The one you&#39;ll keep doing. Photo logging takes the least effort, weighing every ingredient is the
most precise, and many people mix the two: photos for most meals, labels for packaged food.
MealMate does both on iPhone.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Counting reps with Vision and locking apps with Screen Time</title>
      <link>https://treamz.me/blog/counting-reps-with-vision-and-screen-time</link>
      <guid isPermaLink="true">https://treamz.me/blog/counting-reps-with-vision-and-screen-time</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>How I count push-ups from Vision body pose with one state machine, and what Screen Time&apos;s shields, monitor callbacks and 20-activity limit really allow.</description>
      <category>iOS</category>
      <category>Swift</category>
      <category>Vision</category>
      <category>Screen Time</category>
      <category>HealthKit</category>
      <content:encoded><![CDATA[<p>Pull to Refresh is my iPhone app that locks the apps you choose and unlocks them
for exercise reps counted by the camera
(<a href="https://apps.apple.com/us/app/pull-to-refresh-screen-time/id6809189842">App Store</a>).
This is the developer side: counting reps from Vision joint positions, what
Apple&#39;s Screen Time API lets a third-party app do, and the bugs that taught me
both. What the app does for its users is in
<a href="https://treamz.me/blog/app-blocker-unlock-with-exercise">the user-facing post</a>.</p>
<h2 id="one-number-per-exercise-one-state-machine"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#one-number-per-exercise-one-state-machine">One number per exercise, one state machine</a></h2>
<p><a href="https://developer.apple.com/documentation/vision/vndetecthumanbodyposerequest"><code>VNDetectHumanBodyPoseRequest</code></a>
runs on every frame of a 1280×720 front-camera feed. I keep joints with a
confidence of 0.3 or more and drop the frame; nothing is recorded.</p>
<p>What kept the counter small: every exercise reduces a pose to one <code>Double</code>,
oriented so that low means contracted and high means extended. Push-ups, squats
and sit-ups use a joint angle, averaged over both sides of the body when both
are visible. Pull-ups and jumping jacks use the nose&#39;s height against the
wrists, divided by torso length, so standing further back doesn&#39;t change the
number.</p>
<p>One state machine then scores all five; each exercise supplies two thresholds
and a speed limit:</p>
<div class="table"><table>
<thead>
<tr>
<th>Exercise</th>
<th>Signal</th>
<th>Contracted</th>
<th>Extended</th>
<th>Min. gap</th>
</tr>
</thead>
<tbody><tr>
<td>Push-up</td>
<td>elbow angle</td>
<td>100°</td>
<td>150°</td>
<td>0.7 s</td>
</tr>
<tr>
<td>Squat</td>
<td>knee angle</td>
<td>110°</td>
<td>160°</td>
<td>0.7 s</td>
</tr>
<tr>
<td>Sit-up</td>
<td>shoulder–hip–knee angle</td>
<td>80°</td>
<td>120°</td>
<td>0.7 s</td>
</tr>
<tr>
<td>Pull-up</td>
<td>hands above nose, in torsos</td>
<td>0.0</td>
<td>0.35</td>
<td>1.0 s</td>
</tr>
<tr>
<td>Jumping jack</td>
<td>nose above hands, in torsos</td>
<td>0.0</td>
<td>0.8</td>
<td>0.4 s</td>
</tr>
</tbody></table></div>
<p>The gap between the thresholds is the hysteresis: the phase flips to
<code>.contracted</code> below <code>contracted</code>, and a rep counts only on the way back above
<code>extended</code>:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .contracted</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    if</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> value </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">>=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> model.extended {</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">        phase </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .extended</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">        let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> now </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> sample.timestamp</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">        if</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> last </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> lastRepAt, now.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">timeIntervalSince</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(last) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">&#x3C;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> model.minRepInterval {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">            return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">formHint</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">"Slow down — that one didn't count."</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">        }</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">        lastRepAt </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> now</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">        reps </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">+=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 1</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">        return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">counted</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(reps)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    }</span></span></code></pre></div>
<p>The raw value is smoothed first (<code>smoothed + 0.35 * (raw - smoothed)</code>), because
joints jitter between frames. A third line, 35% of the way down, catches
half-reps: dip past it and come back without reaching depth, and you get a
hint to go deeper instead of silence. Up to eight frames of missing joints
pass before the screen says it can&#39;t see you.</p>
<p>The tests drive the counter with synthetic geometry: a pose builder that
produces an exact elbow angle, swept through a cosine at 30 fps. Ten clean
push-ups must give 10, half-reps 0 and a hint, and twenty 0.2-second cycles,
someone shaking the phone, at most 4. Not zero: smoothing and the speed floor
limit how fast reps pay, but nothing recognises shaking as such.</p>
<p>One dead end. I saw zero reps on a screenshot from the start of a squat set,
decided a knee angle can&#39;t be read head-on and moved squats to a side view.
Later frames of that set were counting fine. I reverted it the same day and
fixed the setup hint instead, which had been asking people to stand side-on in
front of the selfie camera. Now every exercise opens on the front camera, so the
person doing the reps can see the count.</p>
<h2 id="five-targets-and-an-app-group"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#five-targets-and-an-app-group">Five targets and an App Group</a></h2>
<p>Screen Time work is split across processes that can&#39;t call each other. The app
picks apps with <code>FamilyActivityPicker</code> and gets opaque tokens, never names. A
<code>DeviceActivityMonitor</code> extension wakes when an interval starts or ends or a
usage threshold is crossed. Two shield extensions draw the lock screen and
handle its buttons. A
<a href="https://developer.apple.com/documentation/deviceactivity/deviceactivityreportextension"><code>DeviceActivityReport</code> extension</a>
is the only process that sees per-app usage, and it can only render a SwiftUI
view: the app shows a &quot;minutes left&quot; card without ever learning the numbers.</p>
<p>They share state through App Group <code>UserDefaults</code>, plus a Darwin notification to
tell a running app something changed. Each rule has its own named
<code>ManagedSettingsStore</code>, so rules shield and lift independently. The monitor is
handed nothing but an activity name, so the name carries the context:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">static</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> func</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> make</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">_</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> kind: ActivityKind, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">rule</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: UUID, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">slot</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Int</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> =</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">-></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> DeviceActivityName {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    DeviceActivityName</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">"</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">\(kind.rawValue)</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">|</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">\(rule.uuidString)</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">|</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">\(slot)</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">var</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> decoded: (kind: ActivityKind, rule: UUID, slot: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Int</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">?</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> parts </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> rawValue.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">split</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">separator</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">"|"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">omittingEmptySubsequences</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">false</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    guard</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> parts.count </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">==</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 3</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">          let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> kind </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> ActivityKind</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">rawValue</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">String</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(parts[</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">])),</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">          let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> id </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> UUID</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">uuidString</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">String</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(parts[</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">1</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">])),</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">          let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> slot </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> Int</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(parts[</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">2</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">]) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> nil</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (kind, id, slot)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p>Two documented limits shape everything: an app and its extensions can monitor
<a href="https://developer.apple.com/documentation/deviceactivity/deviceactivitycenter/monitoringerror/excessiveactivities">twenty activities</a>,
and an interval can&#39;t be shorter than
<a href="https://developer.apple.com/documentation/deviceactivity/deviceactivitycenter/monitoringerror/intervaltooshort">fifteen minutes</a>.
Schedules repeat daily, not hourly, so &quot;five minutes an hour&quot; costs one
registration per hour of its window. Windows are capped at 16 hours, and one
slot always stays free for unlocks.</p>
<h2 id="callbacks-are-not-transitions"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#callbacks-are-not-transitions">Callbacks are not transitions</a></h2>
<p>Up to 1.4 the code did the obvious thing: <code>intervalDidStart</code> lifts the shield,
the threshold puts it back. The docs for
<a href="https://developer.apple.com/documentation/deviceactivity/deviceactivitycenter/startmonitoring(_:during:events:)"><code>startMonitoring</code></a>
say callbacks may arrive as soon as you call it if the interval is running.
Saving a rule re-registered every budget, the registration echoed
<code>intervalDidStart</code>, and the shield came down. And unless an event opts into
counting past activity, which needs iOS 17.4, it counts usage only from the
moment it&#39;s registered, so a save handed a spent allowance back in full.
Stopping an activity echoes <code>intervalDidEnd</code>, so switching a schedule off
mid-lock shielded the rule I&#39;d just been told was off.</p>
<p>The fix was to let no callback decide. Each one hands the rule, as it is now,
to one <code>apply</code>, which asks a pure policy function whether it should be open:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">override</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> func</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> intervalDidStart</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> activity: DeviceActivityName) {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    super</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">intervalDidStart</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: activity)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    guard</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> decoded </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> activity.decoded, decoded.kind </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .grant, decoded.kind </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .warning,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">          let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> rule </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> SharedState.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">rule</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">id</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: decoded.rule) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">    // A fresh allowance, a schedule's boundary, or registering this</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">    // activity echoing straight back. Lifting outright here is what let</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">    // every save reopen an allowance already spent.</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    ShieldEngine.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">apply</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(rule)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    StateBridge.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">postChanged</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">()</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p>That works because of a record: when a threshold fires, the monitor writes down
that the allowance is spent for the current day or hour, so an echo finds the
rule spent and keeps it shut. A save now registers only the rule saved, and
only when something iOS enforces changed. From iOS 17.4, budgets pass
<a href="https://developer.apple.com/documentation/deviceactivity/deviceactivityevent/init(applications:categories:webdomains:threshold:includespastactivity:)"><code>includesPastActivity: true</code></a>
and count the whole day or hour however late they&#39;re registered.</p>
<p>Earned time is a one-shot activity whose event threshold is the time bought.
The threshold counts use of those apps, so putting the phone down doesn&#39;t burn
paid minutes. The interval end is a backstop: four times the unlock, between 30
minutes and 22 hours. It&#39;s registered before anything is stored or lifted; 1.4
did it the other way round, so a refusal at the twenty-activity limit left the
rule open with nothing to close it. And unlock records once lived in one array
that three processes read and wrote whole, so one could resurrect an unlock
another had just ended. Each record has its own key now.</p>
<h2 id="from-the-shield-back-into-the-app"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#from-the-shield-back-into-the-app">From the shield back into the app</a></h2>
<p>The shield&#39;s button belongs to the shield action extension, and for years it
couldn&#39;t launch its containing app. The only way back was a local notification,
easy to miss with a Focus on. My first version posted it and returned <code>.close</code>
in the same breath; closing the shield lets iOS terminate the extension, and the
notification sometimes went with it.</p>
<p>iOS 26.5 added
<a href="https://developer.apple.com/documentation/managedsettings/shieldactionresponse/openparentalcontrolsapp"><code>ShieldActionResponse.openParentalControlsApp</code></a>.
Both paths:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">SharedState.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">setPendingUnlock</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(targets.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">map</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(\.id))</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">if</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> #available</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">iOS</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 26.5</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">*</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) {</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">    // Straight into the workout screen, with the rule already chosen.</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    completionHandler</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(.openParentalControlsApp)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// Closing the shield lets iOS terminate this extension, so the</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// request has to be handed over before that happens.</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Task</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    await</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> notify</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">title</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: title, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">body</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: body, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">ruleID</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: carriedRule)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    completionHandler</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(.close)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p>The pending unlock goes into the App Group first, so the app opens on the
workout screen and spends the next set on that rule. With a minute already
banked, the button just spends it. The new case needs the iOS
26.5 SDK, so CI moved to macOS 26 runners. On older iOS with notifications off
there&#39;s no way back, and the app warns about it.</p>
<p>None of it ships without <code>com.apple.developer.family-controls</code>. The development
entitlement comes without asking and TestFlight worked with it; distribution is
by request, and
<a href="https://developer.apple.com/documentation/familycontrols/requesting-the-family-controls-entitlement">Apple&#39;s page</a>
says to file the same request for every Screen Time extension.</p>
<h2 id="pricing-a-rep"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#pricing-a-rep">Pricing a rep</a></h2>
<p>A clean rep earns 10 seconds for a jumping jack, 20 for a squat or sit-up, 30
for a push-up and 90 for a pull-up. Steps move the rate (×1.15, ×1.3, ×1.6 at
3,000, 8,000 and 15,000), each streak day adds 0.05 up to eight, and the total
caps at ×2. The rate locks when a set starts.</p>
<p>I started with &quot;only reps buy time&quot;, and a test asserts no number of steps pays
a second without a rep. Then a user asked for a way in for people who can&#39;t do a
push-up today, so an opt-in mode now lets 1,000 steps buy 3 minutes, up to 30 a
day, with steps dropped from the multiplier so one walk is paid once. HealthKit
reports a running total, so what today already paid is subtracted, and a total
that drops never takes minutes back.</p>
<p>Steps typed into Health by hand don&#39;t count. My first predicate for that summed
to zero for everyone:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// Must be a negated match, not a "not equal to" match.</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> typedIn </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> HKQuery.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">predicateForObjects</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    withMetadataKey</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: HKMetadataKeyWasUserEntered, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">allowedValues</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: [</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">true</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">])</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> notTypedIn </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> NSCompoundPredicate</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">notPredicateWithSubpredicate</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: typedIn)</span></span></code></pre></div>
<p>A not-equal match only considers samples that carry the key, and step samples
from an iPhone or Watch don&#39;t.</p>
<p>None of this is unbreakable: deleting the app removes every lock, and a camera
can be fooled.</p>
<p>What the app does for the people who use it: <a href="https://treamz.me/blog/app-blocker-unlock-with-exercise">App blocker you unlock with push-ups: Pull to Refresh</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#faq">FAQ</a></h2>
<h3 id="can-you-count-push-ups-with-apples-vision-framework"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#can-you-count-push-ups-with-apples-vision-framework">Can you count push-ups with Apple&#39;s Vision framework?</a></h3>
<p>Yes. <code>VNDetectHumanBodyPoseRequest</code> returns shoulder, elbow and wrist positions
per frame, and the elbow angle follows from them. Pull to Refresh counts a
push-up, on the device, when the angle drops below 100° and comes back above
150°, with smoothing and at least 0.7 seconds between reps.</p>
<h3 id="can-a-screen-time-shield-open-the-app-that-blocked-it"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#can-a-screen-time-shield-open-the-app-that-blocked-it">Can a Screen Time shield open the app that blocked it?</a></h3>
<p>From iOS 26.5, yes: a <code>ShieldActionDelegate</code> can answer with
<code>ShieldActionResponse.openParentalControlsApp</code>. Before that, the fallback is a
local notification, and the extension should wait until the request is added
before calling its completion handler, or iOS may end it first.</p>
<h3 id="how-many-deviceactivity-schedules-can-an-ios-app-monitor-at-once"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#how-many-deviceactivity-schedules-can-an-ios-app-monitor-at-once">How many DeviceActivity schedules can an iOS app monitor at once?</a></h3>
<p>Twenty, across the app and its extensions; past that, <code>startMonitoring</code> throws
<code>excessiveActivities</code>. Each interval must last at least fifteen minutes, and since
schedules repeat daily, an hourly allowance needs one activity per hour.</p>
<h3 id="can-an-ios-app-read-how-long-someone-used-another-app"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#can-an-ios-app-read-how-long-someone-used-another-app">Can an iOS app read how long someone used another app?</a></h3>
<p>Not directly. Per-app usage goes only to a <code>DeviceActivityReport</code> extension,
which can render a SwiftUI view from it but can&#39;t pass the numbers back. Apps
arrive as opaque tokens, not names.</p>
<h3 id="does-the-screen-time-api-work-in-the-ios-simulator"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#does-the-screen-time-api-work-in-the-ios-simulator">Does the Screen Time API work in the iOS Simulator?</a></h3>
<p>No. FamilyControls, ManagedSettings and DeviceActivity have no Simulator
implementation, so authorization fails and no shield appears. You need a
physical iPhone.</p>
<h3 id="do-screen-time-extensions-need-their-own-family-controls-entitlement-request"><a class="anchor" href="https://treamz.me/blog/counting-reps-with-vision-and-screen-time#do-screen-time-extensions-need-their-own-family-controls-entitlement-request">Do Screen Time extensions need their own Family Controls entitlement request?</a></h3>
<p>Yes. Apple&#39;s documentation says to submit the same distribution request for
each Screen Time extension: Device Activity Monitor, Device Activity Report,
Shield Action and Shield Configuration.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Free parking app for Europe: Easy Find Parking for iPhone</title>
      <link>https://treamz.me/blog/free-parking-app-europe</link>
      <guid isPermaLink="true">https://treamz.me/blog/free-parking-app-europe</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>Easy Find Parking is a free iPhone and iPad app that maps free street parking kerb by kerb in Berlin, Madrid, Barcelona, València and Kraków, plus car parks.</description>
      <category>iOS</category>
      <category>Parking</category>
      <category>Travel</category>
      <category>Europe</category>
      <category>Open Data</category>
      <content:encoded><![CDATA[<p>Easy Find Parking is an iPhone and iPad app for drivers that shows where you
can park for free in European cities, kerb by kerb rather than car park by car park. It has
street-level detail for Berlin, Madrid, Barcelona, València (Valencia) and
Kraków, plus car parks with prices and height limits in Germany, Switzerland,
the Netherlands and France, with live space counts where the operator publishes
them. It needs iOS
17 or later and is
<a href="https://apps.apple.com/us/app/easy-find-parking-europe/id6502238318">free to download on the App Store</a>.</p>
<p>I build it on my own, and in September 2026 rebuilt it around the cities&#39; own
open data.</p>
<h2 id="why-is-free-street-parking-hard-to-find-in-a-european-city"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#why-is-free-street-parking-hard-to-find-in-a-european-city">Why is free street parking hard to find in a European city?</a></h2>
<p>Paid zones and charging hours change street by street, are marked by signs you
only see once you&#39;re there, and work differently in every city: Madrid has its
SER zone, Barcelona its blue and green areas. Many councils publish all of it as
open data, but as map files, not as an answer to &quot;can I leave the car here&quot;.
Easy Find Parking reads those files and draws the answer on the kerb.</p>
<h2 id="how-does-easy-find-parking-work"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#how-does-easy-find-parking-work">How does Easy Find Parking work?</a></h2>
<ol>
<li><strong>Open the map.</strong> It centres on you, or on Barcelona if you refuse
location; then pan to where you&#39;re going.</li>
<li><strong>Zoomed out, read the circles.</strong> Green circles count free parking by area,
so you can pick a neighbourhood before setting off.</li>
<li><strong>Zoom in to street level.</strong> Each kerb becomes a line: solid green is free,
dashed teal is likely free, orange is charged right now. A kerb that charges
only during posted hours turns green outside them.</li>
<li><strong>Tap a car park pin</strong> for price, capacity, height limit, opening hours and
live count, whichever the source publishes, and where each fact came from.</li>
<li><strong>Tap Directions</strong> and the route opens in Apple Maps.</li>
</ol>
<p>Filters show free parking only, counted car parks only, or a search radius from
250 m to 5 km.</p>
<figure><img src="https://treamz.me/blog/free-parking-app-europe/free-kerbs-valencia.jpg" alt="The map in València at street level, with free kerbs drawn as green lines on most streets" width="644" height="1400" loading="lazy" decoding="async"><figcaption>València: every green line is a kerb the city publishes as free</figcaption></figure>
<h2 id="where-does-the-parking-data-come-from"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#where-does-the-parking-data-come-from">Where does the parking data come from?</a></h2>
<p>From open data and OpenStreetMap:</p>
<ul>
<li><strong>València</strong> publishes its free spaces outright, about 281,000 of them.</li>
<li><strong>Madrid</strong> publishes 87,000 kerb bands. The 331,000 spaces outside its paid
SER zone show as likely free, because Madrid says &quot;outside the regulated
zone&quot;, not &quot;free&quot;.</li>
<li><strong>Barcelona and Kraków</strong> publish only what they charge for, so the rest of
the city is likely free.</li>
<li><strong>Berlin</strong> is mapped street by street by OpenStreetMap volunteers.</li>
<li><strong>Car parks</strong> in Germany and Switzerland come through ParkAPI, an
open-source project I run my own copy of; Dutch ones from the RDW&#39;s national
register, French ones from transport.data.gouv.fr.</li>
</ul>
<p>Why only five cities at kerb level? When I measured, 99.9% of Berlin&#39;s drivable
streets carried parking tags in OpenStreetMap, against 10% in Paris, 4% in
Barcelona and under 1% in Lisbon. Elsewhere the street layer has to come from
the city itself, and a city that publishes nothing usable gets no layer.</p>
<h2 id="whats-the-difference-between-free-and-likely-free"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#whats-the-difference-between-free-and-likely-free">What&#39;s the difference between &quot;free&quot; and &quot;likely free&quot;?</a></h2>
<p>A parking app that overstates free parking hands you a fine, so the two never
share a label. <strong>Free</strong> means a council publishes it as free, or a street sign
says so. <strong>Likely free</strong> means the kerb is outside every zone the council
charges for, but no sign confirms it; the app says so in words and draws the
line dashed.</p>
<p>The guessing is cautious. In Barcelona, a street with any paid segment within
25 m is left out, and so are main roads. Charging hours that can&#39;t be read count as
charged: being wrong that way costs a walk, the other way a fine. Where there&#39;s
no data, the map says so instead of looking empty.</p>
<h2 id="how-fresh-are-the-live-space-counts"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#how-fresh-are-the-live-space-counts">How fresh are the live space counts?</a></h2>
<p>Each count shows when it was last updated, and one older than 15 minutes turns
grey as &quot;free when last seen&quot;. Only car parks whose city or operator publishes
occupancy have counts: today, several dozen in Germany and Switzerland, most in
Karlsruhe and Basel. One French operator&#39;s feed reported every car park as
empty, so its counts are switched off. The kerb layers describe rules, which
change slowly, not whether a space is empty right now.</p>
<figure><img src="https://treamz.me/blog/free-parking-app-europe/live-counts-basel.jpg" alt="Basel with green and red car park pins, and cards reading 77 of 120 free and 62 of 120 free, updated 1 min ago" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Basel: live counts, each with its age</figcaption></figure>
<h2 id="what-happens-to-my-location"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#what-happens-to-my-location">What happens to my location?</a></h2>
<p>There&#39;s no account. Location is used only while the app is open, to centre the
map and measure walking distance; refuse it and you pan by hand. To fetch
parking, the app sends the coordinates of the area on screen and your filters
to my server, with no account or device identifier attached. There are no ads,
analytics or third-party SDKs. The map is Apple&#39;s MapKit, and Directions hands
the destination to Apple Maps.</p>
<h2 id="who-is-it-for-and-who-isnt-it-for"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#who-is-it-for-and-who-isnt-it-for">Who is it for, and who isn&#39;t it for?</a></h2>
<p>It&#39;s for drivers in Berlin, Madrid, Barcelona, València or Kraków who would
rather not pay for a garage, and for road trips where a car park&#39;s price and
height limit matter before you turn in.</p>
<p>It isn&#39;t for you if:</p>
<ul>
<li><strong>You&#39;re going somewhere else.</strong> Outside those five cities there&#39;s no kerb
layer. Paris has car parks but not streets; Lisbon, Rome, Warsaw and the UK
aren&#39;t covered.</li>
<li><strong>You need to know a kerb has a gap right now.</strong> Lines show rules, not cars.</li>
<li><strong>You want to pay for or book parking.</strong> It does neither.</li>
<li><strong>You need offline use or address search.</strong> It needs a connection, and
there&#39;s no search box yet: you pan the map.</li>
<li><strong>You&#39;re on Android.</strong> It&#39;s iPhone and iPad only, in English.</li>
</ul>
<p>It doesn&#39;t replace the sign either. A market day or roadworks won&#39;t be in any
dataset.</p>
<h2 id="how-to-get-started"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#how-to-get-started">How to get started</a></h2>
<ol>
<li>Install
<a href="https://apps.apple.com/us/app/easy-find-parking-europe/id6502238318">Easy Find Parking from the App Store</a>
on an iPhone or iPad with iOS 17 or later.</li>
<li>Allow location while using the app, or pan to your city.</li>
<li>Zoomed out, find the area with the most free parking, then zoom in.</li>
<li>Tap a car park pin for details, then <strong>Directions</strong> to drive there.</li>
</ol>
<p>Easy Find Parking is an independent app, not affiliated with the cities,
operators or projects whose data it uses; the info button on the map lists
them all.</p>
<p>How it&#39;s built, for developers: <a href="https://treamz.me/blog/free-parking-map-from-open-data">Mapping free street parking from open data with PostGIS and MapKit</a>.</p>
<p>How it compares with ElParking, Telpark, EasyPark, SMOU and Parclick: <a href="https://treamz.me/blog/free-parking-apps-spain">Free parking in Madrid, Barcelona and Valencia: 7 apps compared</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#faq">FAQ</a></h2>
<h3 id="how-can-i-find-free-street-parking-in-a-european-city"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#how-can-i-find-free-street-parking-in-a-european-city">How can I find free street parking in a European city?</a></h3>
<p>Find out where the city&#39;s paid parking zones are: outside them, street parking
is often free, though only the signs say for sure. Many cities publish their
zones as open data. Easy Find Parking, a free iPhone and iPad app, turns that
data into a kerb-by-kerb map for Berlin, Madrid, Barcelona, València and
Kraków.</p>
<h3 id="is-there-an-app-that-shows-free-parking-in-madrid-barcelona-or-valencia"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#is-there-an-app-that-shows-free-parking-in-madrid-barcelona-or-valencia">Is there an app that shows free parking in Madrid, Barcelona or Valencia?</a></h3>
<p>Yes. Easy Find Parking maps free kerbs in Madrid, Barcelona and Valencia, as
well as Berlin and Kraków. Valencia publishes its free spaces directly; in
Madrid and Barcelona they&#39;re worked out from what lies outside the paid zones
and labelled &quot;likely free&quot;.</p>
<h3 id="what-does-likely-free-mean-in-easy-find-parking"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#what-does-likely-free-mean-in-easy-find-parking">What does &quot;likely free&quot; mean in Easy Find Parking?</a></h3>
<p>The kerb lies outside every zone the council charges for, but no sign confirms
it is free. Easy Find Parking draws these kerbs as dashed teal lines, apart
from the solid green kerbs a council or a street sign confirms.</p>
<h3 id="does-easy-find-parking-show-how-many-spaces-are-free-right-now"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#does-easy-find-parking-show-how-many-spaces-are-free-right-now">Does Easy Find Parking show how many spaces are free right now?</a></h3>
<p>For car parks whose city or operator publishes occupancy, yes: a count such as
&quot;77 of 120 free&quot;, with its age. A count older than 15 minutes is shown as &quot;free
when last seen&quot;. Kerbs show where parking is free, not whether a space is
empty.</p>
<h3 id="can-i-find-park-and-ride-p-r-with-easy-find-parking"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#can-i-find-park-and-ride-p-r-with-easy-find-parking">Can I find park and ride (P+R) with Easy Find Parking?</a></h3>
<p>Partly. In the Netherlands it shows P+R and carpool sites from the national
parking register, usually with P+R in the name, and some P+R car parks in
south-west Germany report live counts. There&#39;s no separate P+R filter.</p>
<h3 id="is-easy-find-parking-free"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#is-easy-find-parking-free">Is Easy Find Parking free?</a></h3>
<p>Yes. Easy Find Parking is free to download from the App Store, and version 2.2
has no subscription, in-app purchases or ads.</p>
<h3 id="does-easy-find-parking-track-my-location"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#does-easy-find-parking-track-my-location">Does Easy Find Parking track my location?</a></h3>
<p>It has no account and no analytics or advertising code. To load parking, it
sends the coordinates of the map area on screen to its own server, with no
account or device identifier attached, and uses your location only while the
app is open.</p>
<h3 id="does-easy-find-parking-work-on-android"><a class="anchor" href="https://treamz.me/blog/free-parking-app-europe#does-easy-find-parking-work-on-android">Does Easy Find Parking work on Android?</a></h3>
<p>No. Easy Find Parking is an iPhone and iPad app that needs iOS 17 or later.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Free video editor for iPhone and iPad with no watermark</title>
      <link>https://treamz.me/blog/free-video-editor-iphone</link>
      <guid isPermaLink="true">https://treamz.me/blog/free-video-editor-iphone</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>PremierCut is a free video editor for iPhone, iPad and Mac with no account, no ads and no watermark. Editing, effects and exports happen on your device.</description>
      <category>iOS</category>
      <category>Video Editor</category>
      <category>iPad</category>
      <category>Captions</category>
      <category>Privacy</category>
      <content:encoded><![CDATA[<p>PremierCut is a free video editor for iPhone, iPad and Mac that edits and
renders everything on the device. You cut clips and photos on a multi-track timeline, add
effects, text, captions and sound, and save the result to Photos, with no
account, no ads and no watermark on the export. It&#39;s for people who make short
videos for social media, family or work, it needs iOS 18, iPadOS 18 or macOS 15
or later, and it&#39;s
<a href="https://apps.apple.com/us/app/premiercut/id6824239269">free on the App Store</a>.</p>
<p>I build it on my own. Version 1.0 came out on October 31, 2026. Here&#39;s what it
does and where it stops.</p>
<h2 id="is-premiercut-really-free"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#is-premiercut-really-free">Is PremierCut really free?</a></h2>
<p>Yes, all of it. The download is free, the App Store lists no in-app purchases
and there&#39;s no subscription, so nothing is locked and there&#39;s no paid tier that
removes a watermark. There is no watermark. There are no ads either, and nothing
to sign up for.</p>
<p>What it doesn&#39;t give you is content: no music library, no templates, no stock
footage. Clips and photos come from Photos, audio from the Files app, and you
can import your own fonts (.ttf or .otf) and colour LUTs (.cube).</p>
<h2 id="how-do-i-edit-a-video-in-premiercut"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#how-do-i-edit-a-video-in-premiercut">How do I edit a video in PremierCut?</a></h2>
<ol>
<li><strong>Start a project.</strong> Tap <strong>+</strong> on the project list.</li>
<li><strong>Add media.</strong> <strong>Media</strong> opens the Photos picker on iPhone and iPad and a file
dialog on a Mac. The first clip sets the canvas shape; you can switch it to
9:16, 16:9, 1:1, 4:5 or 4:3.</li>
<li><strong>Cut.</strong> The playhead stays put and the tracks scroll under it, so scrolling
scrubs the preview. Tap a clip to split, trim, duplicate, reverse or delete
it. Clips snap to their neighbours.</li>
<li><strong>Dress it.</strong> Add <strong>Text</strong>, <strong>Audio</strong>, a voiceover with <strong>Record</strong>, and
subtitles with <strong>Captions</strong> on a clip that has speech. Effects go on the
selected clip, transitions on the joint between two clips.</li>
<li><strong>Export.</strong> Tap <strong>Export</strong>, choose the quality, then <strong>Save to Photos</strong> or
<strong>Share</strong>.</li>
</ol>
<figure><img src="https://treamz.me/blog/free-video-editor-iphone/iphone-text.jpg" alt="PremierCut&#39;s Text sheet on iPhone: a title being typed, a font picker, an Import a font file button, alignment, size and colour" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Text on iPhone, with an option to import a font</figcaption></figure>
<h2 id="what-is-premiercut-good-at"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#what-is-premiercut-good-at">What is PremierCut good at?</a></h2>
<ul>
<li><strong>A real timeline.</strong> Several video, overlay, text and audio tracks. Speed runs
from 0.25x to 4x, or you draw a speed curve so one shot slows down and speeds
up. Position, scale, rotation and opacity take keyframes, and you can pinch,
drag and rotate a clip on the preview.</li>
<li><strong>Effects that follow a person.</strong> Body effects use Apple&#39;s on-device person
detection to put outlines, glows and cut-outs on the person in the shot and
keep them there as they move. Background cut-out needs no green screen, and
chroma key, shape masks and blend modes cover the rest.</li>
<li><strong>Plenty to pick from.</strong> More than 100 transitions and dozens of effects and
colour looks, each with sliders. Extra effect packs install as package files.</li>
<li><strong>Captions from speech.</strong> Subtitles from the speech in a clip, in the language
you choose, in one of four looks, at the top, middle or bottom. Each line stays
editable.</li>
<li><strong>Pauses.</strong> Finds the silences in a talking clip, previews what would go, then
cuts them or plays them at 2x, 4x or 8x. It can also remove &quot;um&quot;, &quot;uh&quot; and
repeated words.</li>
<li><strong>Digest.</strong> Picks the best moments of a long clip, judged by loudness, speech,
faces and sharpness, and builds a short version.</li>
<li><strong>The tedious parts.</strong> Auto reframe follows the main subject when you change
the aspect ratio. There&#39;s stabilization, speed changes cut to the beat, and
proxy files that keep large clips smooth while the export uses the originals.</li>
</ul>
<figure><img src="https://treamz.me/blog/free-video-editor-iphone/ipad-effects.jpg" alt="PremierCut on iPad: the Effects sheet over a clip, with the timeline and clip tools such as Split, Speed, Digest, Beat speed and Reframe below" width="1050" height="1400" loading="lazy" decoding="async"><figcaption>On iPad: effects in a sheet, the timeline and clip tools below</figcaption></figure>
<h2 id="what-quality-does-premiercut-export-and-is-there-a-watermark"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#what-quality-does-premiercut-export-and-is-there-a-watermark">What quality does PremierCut export, and is there a watermark?</a></h2>
<p>Video exports at 720p, 1080p or 4K (1280, 1920 or 3840 pixels on the long
side), at 24, 30 or 60 fps, as HEVC for smaller files or H.264 for
compatibility. The default is 1080p HEVC. You can also export only a marked
range, just the sound as M4A, or the frame under the playhead as a PNG.</p>
<p>PremierCut puts nothing of its own on the video: no watermark, no logo.</p>
<h2 id="does-premiercut-upload-my-videos"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#does-premiercut-upload-my-videos">Does PremierCut upload my videos?</a></h2>
<p>No. There&#39;s no account, no analytics and no advertising, the App Store privacy
label reads &quot;Data Not Collected&quot;, and the app makes no network requests of its
own. Editing, effects and exports happen on the device.</p>
<p>Projects live inside the app: the project file, copies of the media you
imported, and caches. Deleting the app deletes them, so export what you want to
keep. A deleted project waits 30 days in Recently deleted. There&#39;s no cloud
sync; to move a project to another device, <strong>Export package</strong> packs it with its
media into one file, and <strong>Open a package</strong> opens it there.</p>
<p>Photos access is asked when you choose media or save a result, the microphone
only for a voiceover, speech recognition only for captions and filler words.
That recognition is Apple&#39;s, and depending on the language Apple may process
the audio on its servers; PremierCut itself sends nothing. Details are in the
<a href="https://premiercut.treamz.me/privacy/">privacy policy</a>.</p>
<h2 id="who-its-for-and-who-it-isnt-for"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#who-its-for-and-who-it-isnt-for">Who it&#39;s for, and who it isn&#39;t for</a></h2>
<p>It&#39;s for you if you cut short videos on a phone or an iPad, want captions on
talking clips without typing them, or don&#39;t want another account and
subscription for it.</p>
<p>It isn&#39;t for you if:</p>
<ul>
<li><strong>You&#39;re on Android or an older system.</strong> It needs iOS 18, iPadOS 18 or
macOS 15 or later.</li>
<li><strong>You want music, templates or stock footage built in.</strong> There are none.</li>
<li><strong>You need cloud sync or to edit with someone else.</strong> Projects stay on one
device unless you move a package yourself.</li>
<li><strong>You edit in landscape on iPhone.</strong> The iPhone app is portrait only. On a Mac
it&#39;s the same app, with a layout designed for touch first.</li>
<li><strong>You need a proven tool today.</strong> It&#39;s a 1.0. Background removal judges each
frame separately, so hair and fast hands can flicker at the edges, and it
keeps everyone in the shot; you can&#39;t pick one person. Digest
finds what&#39;s loud and visible, not what&#39;s funny or important.</li>
<li><strong>You want the interface in another language.</strong> It&#39;s English only.</li>
</ul>
<h2 id="how-to-get-started"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#how-to-get-started">How to get started</a></h2>
<ol>
<li>Install
<a href="https://apps.apple.com/us/app/premiercut/id6824239269">PremierCut from the App Store</a>.</li>
<li>Open it and tap <strong>Try a sample project</strong> to see the editor without your own
footage, or <strong>+</strong> for a new project.</li>
<li>Tap <strong>Media</strong>, allow Photos access, and pick your clips and photos.</li>
<li>Tap a clip and try <strong>Split</strong>, <strong>Speed</strong> or <strong>Effects</strong>, then add <strong>Text</strong>.</li>
<li>Tap <strong>Export</strong>, pick a resolution, and save the video to Photos.</li>
</ol>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#faq">FAQ</a></h2>
<h3 id="how-do-i-find-a-free-video-editor-for-iphone-without-a-watermark"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#how-do-i-find-a-free-video-editor-for-iphone-without-a-watermark">How do I find a free video editor for iPhone without a watermark?</a></h3>
<p>Choose one with no paid tier that removes a watermark, and check a test export
before you put work into it. PremierCut is one: it&#39;s free on the App Store, has
no in-app purchases, and exports up to 4K with no watermark or logo.</p>
<h3 id="is-there-a-video-editor-for-iphone-with-no-account-and-no-ads"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#is-there-a-video-editor-for-iphone-with-no-account-and-no-ads">Is there a video editor for iPhone with no account and no ads?</a></h3>
<p>Yes. PremierCut needs no sign-up, shows no ads, and its App Store privacy label
is &quot;Data Not Collected&quot;. Editing, effects and exports happen on the device.</p>
<h3 id="can-i-edit-videos-on-ipad-for-free"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#can-i-edit-videos-on-ipad-for-free">Can I edit videos on iPad for free?</a></h3>
<p>Yes. PremierCut is free on iPad with iPadOS 18 or later, with the same
timeline, effects, captions and export as on iPhone. It also runs on Macs with
macOS 15 or later.</p>
<h3 id="what-resolution-does-premiercut-export"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#what-resolution-does-premiercut-export">What resolution does PremierCut export?</a></h3>
<p>720p, 1080p or 4K at 24, 30 or 60 fps, as HEVC or H.264. The video can be saved
to Photos or shared, and you can also export just the audio as M4A or one frame
as PNG.</p>
<h3 id="can-premiercut-add-captions-automatically"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#can-premiercut-add-captions-automatically">Can PremierCut add captions automatically?</a></h3>
<p>Yes. Select a clip with speech and tap Captions: PremierCut writes subtitles
with Apple&#39;s speech recognition in the language you choose, and each line can
be edited as text.</p>
<h3 id="how-do-i-remove-silences-from-a-video-on-iphone"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#how-do-i-remove-silences-from-a-video-on-iphone">How do I remove silences from a video on iPhone?</a></h3>
<p>Use an editor that detects silence and cuts it for you. In PremierCut, select a
clip with sound and tap Pauses: it marks the silences, shows how much shorter
the clip gets, and cuts them or speeds them up as one undoable step.</p>
<h3 id="does-premiercut-upload-my-videos-2"><a class="anchor" href="https://treamz.me/blog/free-video-editor-iphone#does-premiercut-upload-my-videos-2">Does PremierCut upload my videos?</a></h3>
<p>No. Projects and media stay inside the app on your device, and PremierCut makes
no network requests of its own. The exception is Apple&#39;s speech recognition for
captions, which may process audio on Apple&#39;s servers depending on the language.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Identifying card printings on device with Vision and OCR</title>
      <link>https://treamz.me/blog/identifying-card-printings-on-device</link>
      <guid isPermaLink="true">https://treamz.me/blog/identifying-card-printings-on-device</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>Artwork matching picked the right card printing 89.5% of the time even given the correct number. How I combine Vision feature prints and OCR, and when to ask.</description>
      <category>iOS</category>
      <category>Swift</category>
      <category>Vision</category>
      <category>Computer vision</category>
      <category>On-device ML</category>
      <content:encoded><![CDATA[<p>How the scanner in Foilcase works out which printing of a trading card it is looking at: Vision feature prints for the artwork, text recognition for the collector number, and a strict rule for when to ask instead of guessing. Plus the storage underneath it: an append-only ledger and one daily price file for everyone. Foilcase is my iPhone app for scanning and pricing cards from five trading card games (<a href="https://apps.apple.com/us/app/foilcase-tcg-card-scanner/id6813665558">App Store</a>); the <a href="https://treamz.me/blog/trading-card-scanner-app">user-facing write-up</a> covers what it does.</p>
<h2 id="why-artwork-matching-picks-the-wrong-print"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#why-artwork-matching-picks-the-wrong-print">Why artwork matching picks the wrong print</a></h2>
<p>Before release I built a harness on a Mac that calls the same Vision APIs as the app: 9,903 cards indexed, 400 queries, half drawn on purpose from collector numbers that more than one print shares. Each query was a synthetic degradation of an official card image (perspective, blur, glare, colour cast, JPEG).</p>
<div class="table"><table>
<thead>
<tr>
<th>Sharp frame, 400 queries</th>
<th>Correct</th>
</tr>
</thead>
<tbody><tr>
<td>Artwork, top-1</td>
<td>87.8%</td>
</tr>
<tr>
<td>Artwork, top-1 on shared numbers</td>
<td>78.5%</td>
</tr>
<tr>
<td>Artwork, top-5</td>
<td>99.8%</td>
</tr>
<tr>
<td>Artwork, top-1 when given the correct number</td>
<td>89.5%</td>
</tr>
</tbody></table></div>
<p>The last row changed the design. Even with the right collector number, artwork matching picks the right print 89.5% of the time, because alternate arts and higher-rarity parallels often share one number and one illustration. No embedding separates two prints of one picture, so the app asks which print, as it asks whether a card is foil. And since five candidates nearly always include the right card, the five-card sheet is the main flow, not an error state.</p>
<p>The embedding is <a href="https://developer.apple.com/documentation/vision/vngenerateimagefeatureprintrequest"><code>VNGenerateImageFeaturePrintRequest</code></a>, revision 2: 768 floats, about 9 ms an image. One option matters more than it looks:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">static</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> func</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> featurePrint</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">of</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> image: CIImage) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">-></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> [</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Float</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">]</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">?</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> request </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> VNGenerateImageFeaturePrintRequest</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">()</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    request.imageCropAndScaleOption </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .scaleFill</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    try?</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> VNImageRequestHandler</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">ciImage</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: image, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">options</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: [</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">]).</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">perform</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">([request])</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    guard</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> observation </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> request.results</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">?</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.first </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> nil</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    var</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> vector </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> [</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Float</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">](</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">repeating</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">count</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: observation.elementCount)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    _ </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> vector.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">withUnsafeMutableBytes</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { observation.data.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">copyBytes</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">to</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">$0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> vector</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">static</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> artworkMatchingAvailable: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Bool</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> =</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> probe </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> CIImage</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">color</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: .gray).</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">cropped</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">to</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">CGRect</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">x</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">y</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">width</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">64</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">height</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">90</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">))</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> featurePrint</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">of</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: probe) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!=</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> nil</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}()</span></span></code></pre></div>
<p>The default, <code>.centerCrop</code>, cuts the top and bottom off a tall card: 82.4% against 93.2% for <a href="https://developer.apple.com/documentation/vision/vnimagecropandscaleoption/scalefill"><code>.scaleFill</code></a> on the same 1,295-card index. The probe exists because feature prints fail in the Simulator (&quot;Failed to create espresso context&quot;). The app checks once and falls back to the collector number alone, as it would on any device without a usable neural engine.</p>
<h2 id="distance-is-not-a-confidence-score"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#distance-is-not-a-confidence-score">Distance is not a confidence score</a></h2>
<p>The obvious next step is a distance threshold. Measured, it says almost nothing: median distance was 0.450 when the top match was right and 0.420 when it was wrong. But distance does tell whether there is a card at all: photographed cards sat at a median of 0.459, a phone pointed at a laptop at 0.966. I found that out when the scanner, with no ceiling, turned a browser window into twenty-six confident entries. The ceiling is now 0.80, which keeps 99% of photographed cards and rejects 89% of everything else.</p>
<h2 id="reading-the-collector-number"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#reading-the-collector-number">Reading the collector number</a></h2>
<p>The number is unambiguous whenever it is legible, so most of the work went into legibility.</p>
<p><strong>Drop soft frames.</strong> Between a handheld frame and a sharp one, the artwork gained 5.6 points and the collector number 46 (32.8% read against 78.5%). The gate is the variance of the Laplacian on a greyscale buffer of the rectified card. My first version chained Core Image filters, a Laplacian then a multiply blend, and returned negative variances: blend modes clamp and premultiply, they are not arithmetic.</p>
<p><strong>Don&#39;t ask Vision to find the card.</strong> On iOS, <code>VNDetectDocumentSegmentationRequest</code> returned the card&#39;s text box at 0.96 confidence, and <code>VNDetectRectanglesRequest</code> found artwork panels, never the border. So the scanner draws a card-shaped guide, tries three crops around it and keeps whichever embeds closest to the index.</p>
<p><strong>Read in passes, at full size.</strong> Downscaling crops is how my first benchmark reported an OCR accuracy of zero. <a href="https://developer.apple.com/documentation/vision/vnrecognizetextrequest"><code>VNRecognizeTextRequest</code></a> runs over the whole card, then the top and bottom 22% enlarged 2.5 times, then three corners at 4 times if nothing turned up:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">private</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> func</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> scan</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">_</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> image: CIImage, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">into</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> found: </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">inout</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> Set</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">&#x3C;</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">String</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">>, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">minimumTextHeight</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Float</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> =</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0.008</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> request </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> VNRecognizeTextRequest</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">()</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    request.recognitionLevel </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .accurate</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    request.usesLanguageCorrection </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> false</span><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">   // these are codes, not words</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    request.recognitionLanguages </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> [</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">"en-US"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">]</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    request.minimumTextHeight </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> minimumTextHeight   </span><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// the default floor skips the number</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    try?</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> VNImageRequestHandler</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">ciImage</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: image, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">options</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: [</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">]).</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">perform</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">([request])</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> observation </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">in</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> request.results </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">??</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> [] {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">        for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> candidate </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">in</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> observation.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">topCandidates</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">2</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) {</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">            found.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">formUnion</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(PrintedNumber.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">extract</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">from</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: candidate.string))</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">        }</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    }</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p>The recognizer swaps look-alikes (S and 5, O and 0, B and 8), so readings are folded before comparing. That tolerance is what kept producing wrong additions. The fix was two keys: a loose one, padding removed, for <em>finding</em> candidates, and a literal one, padding and variant marks kept, for <em>adding</em> without asking. &quot;45/195&quot; fits a catalog &quot;045/195&quot;, but it is also what &quot;145/195&quot; looks like with its first digit lost.</p>
<h2 id="when-the-scanner-adds-and-when-it-asks"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#when-the-scanner-adds-and-when-it-asks">When the scanner adds and when it asks</a></h2>
<p>Add without asking only when the artwork&#39;s first choice (or a card it can&#39;t tell apart from it) and a reading that spells that card&#39;s number literally name the same card, and nothing the artwork can&#39;t rule out shares that number. Everything else is the sheet. Trimmed from <code>ScanPipeline.decide</code>:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">func</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> blocks</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">_</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> card: CardRef) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">-></span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> Bool</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    guard</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> card.id </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> winner.id </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> false</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    guard</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> baseName</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(card) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> baseName</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(winner.card) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> true</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }   </span><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// same card, other print</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    guard</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> margin </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> policy.distinctParallelMargin, </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> mine </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> winner.distance,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">          let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> theirs </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> distances[card.id] </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> true</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> theirs </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">&#x3C;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> mine </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">*</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">1</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> +</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> margin)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">if</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> parallels.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">contains</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">where</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: blocks) { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">ambiguous</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">candidates</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: offered, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">reason</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: .parallelPrints, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">numbers</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: numbers) }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> artworkAgrees </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> winner.id </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">==</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> candidates[</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">].id</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    ||</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> artworkTies</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(candidates[</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">], winner, policy.minimumRelativeMargin)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> byNumber </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> winner.numberMatched </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">&#x26;&#x26;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> artworkAgrees</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    &#x26;&#x26;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">spelled</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(winner.card, literals) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">||</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> unpadded)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">guard</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> byNumber </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">ambiguous</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">candidates</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: ordered, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">reason</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: winner.numberMatched </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">?</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .numberDisagrees </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .lowConfidence, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">numbers</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: numbers)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">identified</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(winner)</span></span></code></pre></div>
<p>A different card sharing the number stops blocking only if the artwork puts it at least 15% farther away. <code>unpadded</code> lets an old card printed &quot;18/130&quot; match a catalog &quot;018/130&quot;, but only when it lands on the artwork&#39;s own first pick.</p>
<p>The rule took three rounds of adversarial review to reach, each reported bug reproduced against the real catalogs. The first confirmed 33 defects; the second found 12 new ones, 11 introduced by the fixes. As my commit message put it, a comparison built to <em>find</em> candidates was also allowed to <em>add</em> one. Adding on artwork margin alone went too: a card missing from the index still has a nearest neighbour that can be clearly ahead and wrong.</p>
<p>Before a silent add, a second text request also counts Han, kana and hangul characters, because artwork and number are identical across languages and each catalog holds one. English cards read 0 to 3, Japanese ones 37 to 188; the cut-off is 8.</p>
<p>Then I measured on 529 real photographs of cards, labelled against the catalog, with all five games installed (62,331 cards in one index) and the app&#39;s recognition code running on a Mac. Every silent add was checked by eye:</p>
<div class="table"><table>
<thead>
<tr>
<th>529 real photos</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Added without asking, wrong card</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>Added without asking, right card</td>
<td>21%</td>
<td>42%</td>
</tr>
<tr>
<td>Right card added, or first in the sheet</td>
<td>69%</td>
<td>90%</td>
</tr>
<tr>
<td>Collector number read</td>
<td>59%</td>
<td>77%</td>
</tr>
</tbody></table></div>
<p>The gains came from the photos: a picked photo now skips the quality gates, since no better frame is coming; the number is read from the loosest crop when the tightest cut it off; and each crop is also embedded slightly blurred, because a holo&#39;s sparkle pulled matches away from the flat scans in the index (right card first: 377 to 397). The live camera path is still unmeasured; for that, the app keeps an on-device calibration log of each attempt and the card the user actually picked.</p>
<h2 id="keeping-a-27-000-card-index-on-the-phone"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#keeping-a-27-000-card-index-on-the-phone">Keeping a 27,000-card index on the phone</a></h2>
<p>Each game&#39;s index downloads separately, quantised to int8 with one scale per pack. On queries perturbed to the distance a handheld photo sits at, int8 and float16 returned the same card 99.8% of the time, for half the bytes. There&#39;s no approximate nearest-neighbour library: in the Mac harness, a brute-force scan of the largest index, 26,978 vectors, takes 1.6 ms widened to <code>Float</code>, but holds 83 MB. So the app memory-maps the int8 bytes and converts 512 rows at a time with <a href="https://developer.apple.com/documentation/accelerate/vdsp_vflt8"><code>vDSP_vflt8</code></a> and <a href="https://developer.apple.com/documentation/accelerate/vdsp_distancesq"><code>vDSP_distancesq</code></a>: 5.6 ms, nothing resident. The scan runs only on a steady frame, so four milliseconds is invisible and 83 MB isn&#39;t. A pack from a different Vision revision is refused; its distances live in another space.</p>
<p>The index is built on a Mac and lags the catalog, so a clearly read number naming a card the artwork never offered goes to the top of the sheet, asked about and never added.</p>
<h2 id="a-ledger-that-never-overwrites-and-one-price-file-for-everyone"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#a-ledger-that-never-overwrites-and-one-price-file-for-everyone">A ledger that never overwrites, and one price file for everyone</a></h2>
<p>The collection is an append-only <code>ledger</code> table plus an <code>items</code> table derived from it in the same transaction. Removal is a tombstone; <code>rebuild()</code> replays the log. Replay needs an order every device agrees on, and the middle term of the sort key is a bug fix: the scanner adds a card and sets its finish within one millisecond, and a random event id sometimes sorted the edit first.</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">/// Creation sorts before every edit to the same item at the same instant.</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">var</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> replayRank: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Int</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    if</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .added </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> self</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 1</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> record </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">in</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> records.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">sorted</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">by</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: { (</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">$0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.timestamp, </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">$0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.event.replayRank, </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">$0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.eventID)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">                                 &#x3C;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">$1</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.timestamp, </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">$1</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.event.replayRank, </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">$1</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.eventID) }) {</span></span></code></pre></div>
<p>It paid off in October. The upstream catalog renamed sets, card ids include the set name, and 1,323 cards in one game got new ids overnight, 222 more across the others. Copies saved under old ids dropped off the collection screen. The fix is an alias table read in memory; the log stays as written.</p>
<p>Prices are one binary file per game, the same bytes for every user, so a request says nothing about what anyone owns. Names are content-addressed and the manifest is written last, so a client never sees half an update. Prices are integer cents, with a sentinel so a $0.00 promo isn&#39;t an unpriced card. Each carries the day it was captured, and past <code>PriceProvenance.staleAfterDays</code> (7) the portfolio headline says &quot;These prices are 2 weeks old&quot; instead of presenting an old total as current.</p>
<p>What the app does for the people who use it: <a href="https://treamz.me/blog/trading-card-scanner-app">Scan trading cards and see what they&#39;re worth with Foilcase</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#faq">FAQ</a></h2>
<h3 id="how-do-you-tell-apart-card-printings-that-share-the-same-artwork"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#how-do-you-tell-apart-card-printings-that-share-the-same-artwork">How do you tell apart card printings that share the same artwork?</a></h3>
<p>Mostly not from the image: in my pre-release harness, artwork matching picked the right print 89.5% of the time even when given the correct collector number. Foilcase reads the number with Vision text recognition and, when other prints share it, shows the candidates and asks.</p>
<h3 id="is-vngenerateimagefeatureprintrequest-good-enough-for-on-device-image-recognition"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#is-vngenerateimagefeatureprintrequest-good-enough-for-on-device-image-recognition">Is VNGenerateImageFeaturePrintRequest good enough for on-device image recognition?</a></h3>
<p>For retrieval, yes, with no model to ship: the right card was in the top five 99.8% of the time on sharp synthetic frames of a 9,903-card index. Use <code>.scaleFill</code>, and test on a device, because it doesn&#39;t run in the Simulator.</p>
<h3 id="do-i-need-an-approximate-nearest-neighbour-library-for-on-device-image-search"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#do-i-need-an-approximate-nearest-neighbour-library-for-on-device-image-search">Do I need an approximate nearest neighbour library for on-device image search?</a></h3>
<p>Not at tens of thousands of vectors. A brute-force Accelerate scan over 26,978 feature prints took 1.6 ms on a Mac in Float, or 5.6 ms as memory-mapped int8, which avoids holding 83 MB in memory.</p>
<h3 id="can-embedding-distance-be-used-as-a-confidence-score"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#can-embedding-distance-be-used-as-a-confidence-score">Can embedding distance be used as a confidence score?</a></h3>
<p>Not between similar images: median distance was 0.450 for correct matches and 0.420 for wrong ones. It does separate cards (0.459) from non-cards (0.966), so Foilcase uses it only as a 0.80 ceiling.</p>
<h3 id="how-do-you-read-small-printed-numbers-with-vnrecognizetextrequest"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#how-do-you-read-small-printed-numbers-with-vnrecognizetextrequest">How do you read small printed numbers with VNRecognizeTextRequest?</a></h3>
<p>Lower <code>minimumTextHeight</code>, turn off language correction, never downscale the crop, and add passes over enlarged strips and corners. Rejecting blurry frames first matters most: it took collector-number accuracy from 32.8% to 78.5% in my harness.</p>
<h3 id="why-store-a-collection-as-an-append-only-ledger"><a class="anchor" href="https://treamz.me/blog/identifying-card-printings-on-device#why-store-a-collection-as-an-append-only-ledger">Why store a collection as an append-only ledger?</a></h3>
<p>Nothing is overwritten, so a mis-tap or a bug can&#39;t destroy data: removal is a tombstone and the item table is rebuilt from the log. Changed upstream ids resolve through an alias table at read time, without touching user data.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Mapping free street parking from open data with PostGIS and MapKit</title>
      <link>https://treamz.me/blog/free-parking-map-from-open-data</link>
      <guid isPermaLink="true">https://treamz.me/blog/free-parking-map-from-open-data</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>How I turn cities&apos; paid-parking data into likely-free kerbs with PostGIS, why OSM parking tags fail outside Germany, and how SwiftUI&apos;s Map draws them.</description>
      <category>iOS</category>
      <category>PostGIS</category>
      <category>OpenStreetMap</category>
      <category>Open Data</category>
      <category>MapKit</category>
      <content:encoded><![CDATA[<p>This post covers why OpenStreetMap gives kerb-level parking in only some
cities, how I turn a council&#39;s paid streets into &quot;likely free&quot; kerbs with
PostGIS, how that guess stays apart from real facts, and how SwiftUI&#39;s <code>Map</code>
draws a city&#39;s kerbs without stuttering. The app is Easy Find Parking, a free
iPhone and iPad map of free street parking in Berlin, Madrid, Barcelona,
València and Kraków,
<a href="https://apps.apple.com/us/app/easy-find-parking-europe/id6502238318">on the App Store</a>;
the user&#39;s side is in <a href="https://treamz.me/blog/free-parking-app-europe">a separate post</a>.</p>
<p>Server: Flask, PostGIS and a self-hosted ParkAPI. Phone: SwiftUI on iOS 17.</p>
<h2 id="openstreetmap-street-parking-is-mostly-a-german-dataset"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#openstreetmap-street-parking-is-mostly-a-german-dataset">OpenStreetMap street parking is mostly a German dataset</a></h2>
<p>My first plan was OpenStreetMap everywhere. Kerbside parking is tagged on the
street itself, with keys like <code>parking:left=lane</code> and <code>parking:right:fee=no</code>
(see <a href="https://wiki.openstreetmap.org/wiki/Street_parking">Street parking</a> on
the OSM wiki). When I measured in September 2026, this was the share of
drivable streets carrying any <code>parking:*</code> tag:</p>
<div class="table"><table>
<thead>
<tr>
<th>City</th>
<th align="right">Streets with <code>parking:*</code> tags</th>
</tr>
</thead>
<tbody><tr>
<td>Berlin</td>
<td align="right">99.9%</td>
</tr>
<tr>
<td>Hamburg</td>
<td align="right">54%</td>
</tr>
<tr>
<td>Warsaw</td>
<td align="right">26%</td>
</tr>
<tr>
<td>Lyon</td>
<td align="right">22%</td>
</tr>
<tr>
<td>Milan</td>
<td align="right">11%</td>
</tr>
<tr>
<td>Paris</td>
<td align="right">10%</td>
</tr>
<tr>
<td>València</td>
<td align="right">7%</td>
</tr>
<tr>
<td>Barcelona</td>
<td align="right">4%</td>
</tr>
<tr>
<td>Lisbon</td>
<td align="right">0.9%</td>
</tr>
</tbody></table></div>
<p>Fee sub-tags were on about a tenth of those. Berlin works: 64,229 kerb sides and
48,567 car parks from one Geofabrik extract. Elsewhere a tag-driven layer would
be mostly blank, which reads as &quot;no parking here&quot;. So outside Germany the
street layer comes from municipal open data, which the city actually enforces.</p>
<p>Reading the tags has traps. The wiki deprecated the older <code>parking:lane=*</code> and
<code>parking:condition=*</code> keys in December 2022 and asks for existing ones to be
converted, so the normaliser reads both. <code>parking:left=no</code> is a value, not an
absence: 43% of Berlin&#39;s tagged sides. And without <code>closed_ways_are_polygons</code>
in its config, GDAL put closed <code>amenity=parking</code> ways in the lines layer, where
my highway filter dropped them: Berlin had 42 car-park polygons instead of
63,294.</p>
<h2 id="deriving-likely-free-from-what-a-city-charges-for"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#deriving-likely-free-from-what-a-city-charges-for">Deriving &quot;likely free&quot; from what a city charges for</a></h2>
<p>Councils publish what they charge for, because that&#39;s what they enforce.
Barcelona&#39;s
<a href="https://opendata-ajuntament.barcelona.cat/data/dataset/trams-aparcament-superficie">surface parking dataset</a>
lists its blue, green and goods-delivery segments; Kraków publishes 23 paid-zone
polygons and no kerbs. The complement is the free map: every drivable street
inside the city boundary with nothing regulated near it.</p>
<div class="code" data-lang="sql"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">CREATE</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> TEMP </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">TABLE</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> regulated </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">AS</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">SELECT</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> geom </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">FROM</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> efp.street_parking</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">WHERE</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> city_id </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> :</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'city'</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> AND</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> fee_source </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">IN</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'municipal_paid'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'municipal_free'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">UNION ALL</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">SELECT</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> geom::</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">geography</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> FROM</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> efp.paid_zone </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">WHERE</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> city_id </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> :</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'city'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">;  </span><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">-- Kraków's polygons</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">-- ...</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">FROM</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> osm_stage.streets_REGIONTAG s</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">CROSS JOIN</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">VALUES</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'left'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">), (</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'right'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">AS</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> sides(side)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">WHERE</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> EXISTS</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">SELECT</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 1</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> FROM</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> city_boundary b </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">WHERE</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ST_Within(s.geom, b.geom))</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  AND</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> NOT</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> EXISTS</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">      SELECT</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 1</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> FROM</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> regulated r</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">      WHERE</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ST_DWithin(efp.kerb_offset(s.geom, sides.side)::</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">geography</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, r.geom, :clearance))</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  AND</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> s.highway </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">IN</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'residential'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'living_street'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'unclassified'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'tertiary'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'tertiary_link'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  AND</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> lower</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">coalesce</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(s.access, </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">''</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">NOT</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> IN</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'private'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'no'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'customers'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span></code></pre></div>
<p>Three of these are there because a looser version published something wrong.</p>
<p><strong>Metres, not degrees.</strong> The first version buffered by 0.00025°. At
Barcelona&#39;s latitude that&#39;s 27.8 m north–south but 20.8 m east–west, and 407
streets 21–25 m from a paid kerb went out as free. Casting to <code>geography</code> makes
<code>ST_DWithin</code> measure in metres; <code>:clearance</code> is 25.</p>
<p><strong>Test the kerb, not the centreline.</strong> <code>efp.kerb_offset</code> shifts the street 3 m
to one side, working in EPSG:3857 and dividing the distance by cos(latitude) to
undo Mercator&#39;s stretch. That line can sit closer to a paid kerb than the
centreline, so it&#39;s the one tested.</p>
<p><strong>Leave out roads with no parking.</strong> The first run turned 17,275 regulated
segments into 10,842 free kerb sides, and the map showed dashed &quot;likely free&quot;
lines down Avinguda Diagonal. Arterials have no regulated segments because
nobody parks on them, so &quot;outside every paid zone&quot; came out true. Limiting the
rule to residential, unclassified and tertiary roads dropped 3,156 sides, 29%
of the layer. Service roads stay out: they&#39;re car-park aisles and driveways as
often as public kerb.</p>
<p>Kraków&#39;s polygons feed the same rule: 21,255 free kerb sides, none within 25 m
of a zone. A test samples 2,000 derived kerbs per city to assert
it. Checking every row took over 150 seconds at 29,000 derived against
105,000 regulated, and a check nobody waits for is a check nobody runs.</p>
<h2 id="free-and-likely-free-are-different-values"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#free-and-likely-free-are-different-values">Free and likely free are different values</a></h2>
<p>The schema never stores a bare &quot;free&quot;. Every row has a <code>fee_state</code> (<code>FREE</code>,
<code>PAID</code>, <code>CONDITIONAL</code>, <code>UNKNOWN</code>) and a <code>fee_source</code> saying where it came from:
<code>municipal_free</code>, <code>osm_tag</code>, <code>operator_feed</code>, <code>municipal_paid_complement</code> and
<code>municipal_outside_zone</code>.</p>
<p>The last one came from Madrid, whose SER map service publishes 87,614 kerb
bands. 53,158 are grey, a colour missing from the service&#39;s own legend.
Sampling 300 bands of each colour against the SER boundary settled it: every
blue and green band tested fell inside, no grey one did. So grey bands are
published kerbs outside the paid zone: stronger than an inference from nothing,
weaker than the council saying &quot;free&quot;. I first stored them as
<code>municipal_paid_complement</code>, and the overlap test failed, correctly: grey bands
sit next to blue ones on the same street, which for a derived street would be a
bug.</p>
<p>One function turns the pair into a claim, ordered by trust: a current count, a
published fact, a schedule, an inference, an admitted gap.</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">switch</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (feeState, feeSource) {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (.free, .municipalFree), (.free, .operatorFeed)</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .cityFree</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (.free, .osmTag)</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .signpostedFree</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (.free, .municipalOutsideZone), (.free, .municipalPaidComplement)</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">    // This is the inference, and it must never borrow a stronger phrasing.</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .likelyFree</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (.free, _)</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">    // A free claim with no provenance is not one we will repeat.</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .unknown</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">default:</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    break</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p>Charging hours the parser can&#39;t read resolve to &quot;charged right now&quot;: wrong
that way costs a walk, the other way a fine.</p>
<p>On the first build the inferred layer was mint and read as the same free as
the confirmed green. Now it&#39;s teal and dashed, which says &quot;not solid&quot; before
anyone reads the legend:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">ForEach</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(model.streets) { street </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">in</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    MapPolyline</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">coordinates</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: street.coordinates)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">        .</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">stroke</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(street.claim.tint.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">opacity</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">0.85</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">),</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">                style</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: street.claim.isInferred </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">?</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .inferredKerb </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .kerb)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">private</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> extension</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> StrokeStyle</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    static</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> kerb </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> StrokeStyle</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">lineWidth</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">4</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">lineCap</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: .round, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">lineJoin</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: .round)</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">    /// Dashed: derived from an absence of regulation, not from a sign.</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    static</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> inferredKerb </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> StrokeStyle</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">        lineWidth</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">4</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">lineCap</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: .butt, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">lineJoin</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: .round, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">dash</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: [</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">3</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">7</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">])</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p>Both hedged kinds draw the same dashed teal; the data keeps them apart.</p>
<h2 id="drawing-kerbs-with-swiftuis-map"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#drawing-kerbs-with-swiftuis-map">Drawing kerbs with SwiftUI&#39;s Map</a></h2>
<p>That <code>ForEach</code> is the whole renderer, and its cost is the count: a city-wide
Barcelona viewport holds around 660 kerbs. The obvious fix is one
<code>MKMultiPolyline</code> per style, about four overlays, but <code>MapPolyline</code> has no
initialiser that takes one. So I rewrote the map as <code>MKMapView</code> in a
<code>UIViewRepresentable</code>. It worked in the simulator and drew no tiles on a real
device. I couldn&#39;t reproduce it, and a blank map is worse than a slow one, so I
went back to SwiftUI&#39;s <code>Map</code>. What keeps it smooth:</p>
<ul>
<li><strong>Measure first.</strong> The database answered in 7.6 ms and the endpoint in under
half a second, so the stutter was on the phone.</li>
<li><strong>Fetch a ring, draw the screen.</strong> The app fetches 30% of the span beyond
each edge, skips the request while the viewport stays inside it, and waits
250 ms for the camera to settle. Drawing is clipped to the viewport, about
60% fewer lines, and the array is reassigned only when visible IDs change:
an identical array still makes SwiftUI diff every overlay.</li>
<li><strong>Slim payload.</strong> Street name, zone, capacity and source were 65% of the
bytes and none is drawn, so the layer gets four fields per kerb and
coordinates rounded to six decimals, about 10 cm. A city-wide response went
from 575 KB to 359 KB.</li>
<li><strong>Nearest first.</strong> The query orders by <code>sp.geom &lt;-&gt; centre</code> with a limit of
1,500, so truncation eats the ring, not the middle of the screen.</li>
</ul>
<p>One feature from the live API:</p>
<div class="code" data-lang="json"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">{</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">  "type"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">"Feature"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">  "geometry"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    "type"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">"LineString"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    "coordinates"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: [[</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">2.174812</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">41.403032</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">], [</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">2.174847</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">41.403005</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">], [</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">2.174876</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">41.402983</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">]]</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">  },</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">  "properties"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    "source_id"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">"complement-es-barcelona-885702924-left"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    "fee_state"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">"FREE"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    "fee_source"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">"municipal_paid_complement"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    "opening_hours"</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">null</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">  }</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p>A cap of 400 drawn kerbs left the edges of a Barcelona screen bare, and a
driver can&#39;t tell a capped edge from a street with no parking, so it went.</p>
<p>València publishes individual spaces, 315,303 averaging 2.6 m, so 1,500
features covered a few blocks. <code>ST_ClusterDBSCAN</code>, partitioned by street, fee
state and a grid of about 300 m, now merges adjacent bays into segments with a
bay count: the same view went from 1,500 features and 453 KB to 582 and 135 KB.
Without the grid, nameless paid bays chained into lines 25 km long. A polyline
threaded through the bays zig-zagged across the street, since bays sit on both
sides, so each cluster is drawn as the longest line across its convex hull.</p>
<figure><img src="https://treamz.me/blog/free-parking-app-europe/free-kerbs-valencia.jpg" alt="The map in València at street level, with free kerbs drawn as solid green lines" width="644" height="1400" loading="lazy" decoding="async"><figcaption>València: each green line is a run of free bays merged into one segment</figcaption></figure>
<p>Zoomed out past 0.02° of span, the kerb layer switches off and the gateway
counts kerbs in roughly an 8×8 grid over the viewport, drawn as circles.
These aren&#39;t MapKit annotation clusters: the phone never downloads the kerbs at
that zoom. Circle size is logarithmic, because counts span four orders of
magnitude and linear sizing pinned nearly every circle to the maximum.</p>
<h2 id="live-counts-go-stale-after-15-minutes"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#live-counts-go-stale-after-15-minutes">Live counts go stale after 15 minutes</a></h2>
<p>The old app read <code>api.parkendd.de</code>, which had stopped updating. Now I run my
own <a href="https://github.com/ParkenDD/park-api-v3">ParkAPI v3</a> (MIT), pulling
realtime data every 300 seconds into the same Postgres as my schema, so the
gateway joins its <code>parking_site</code> table directly. The gateway decides freshness,
so phone and server can&#39;t disagree:</p>
<div class="code" data-lang="python"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">STALE_AFTER </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> timedelta(minutes</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">15</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">def</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> freshness</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(updated_at: datetime </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">|</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> None</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) -> </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">str</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">:</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    if</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> updated_at </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">is</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> None</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">:</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">        return</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4"> 'none'</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    age </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> datetime.now(timezone.utc) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">-</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> updated_at</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4"> 'live'</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> if</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> age </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">&#x3C;=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> STALE_AFTER </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4"> 'stale'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> row </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">in</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> rows:</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    row[</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'freshness'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">] </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> freshness(row.get(</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'realtime_updated_at'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">))</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">    # Keep the number, drop the claim that it is current.</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    row[</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'realtime_is_current'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">] </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> row[</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'freshness'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">] </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">==</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4"> 'live'</span></span></code></pre></div>
<p>ParkAPI&#39;s own public API drops realtime fields once they&#39;re too old. I keep the
number and label it: a stale count shows grey as &quot;N free when last seen&quot;, with
its age. On 9 November 2026, 15 car parks within 2 km of central Basel had a
current count.</p>
<p>Indigo&#39;s feed reported zero occupancy for all 558 French car parks, which naive
arithmetic shows as empty, so realtime is off for it. And 28% of upstream
ParkAPI converters were already dead when I set it up, so each source&#39;s last
success and error live in a table.</p>
<h2 id="licences-and-gaps"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#licences-and-gaps">Licences and gaps</a></h2>
<p>OpenStreetMap data is ODbL (© OpenStreetMap contributors). Barcelona and
València publish under CC BY 4.0, Madrid and Kraków under their own open-data
terms, the French national base and Indigo under ODbL. Derived layers credit
both the council and OSM, and the app shows these credits behind its info
button.</p>
<p>Missing: an offline cache, address search, and Warsaw. Its city API timed out
when I checked, and OSM had fee information on 0% of its central streets. Kerb
lines show rules, not empty spaces.</p>
<p>What the app does for the people who use it: <a href="https://treamz.me/blog/free-parking-app-europe">Free parking app for Europe: Easy Find Parking for iPhone</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#faq">FAQ</a></h2>
<h3 id="how-complete-is-openstreetmap-parking-data-in-european-cities"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#how-complete-is-openstreetmap-parking-data-in-european-cities">How complete is OpenStreetMap parking data in European cities?</a></h3>
<p>Very uneven. When I measured in September 2026, the share of drivable streets
with any <code>parking:*</code> tag was 99.9% in Berlin, 10% in Paris, 4% in Barcelona
and 0.9% in Lisbon, and about a tenth of tagged streets had fee
information. Outside Germany, municipal open data is usually the better source.</p>
<h3 id="how-do-you-find-free-street-parking-from-a-citys-paid-parking-data"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#how-do-you-find-free-street-parking-from-a-citys-paid-parking-data">How do you find free street parking from a city&#39;s paid parking data?</a></h3>
<p>Take the OpenStreetMap road network inside the city boundary and drop any
street side within a clearance (25 m in Easy Find Parking) of a published paid
segment or zone polygon. Limit it to residential, unclassified and tertiary
roads, or arterials come out free, and label the result &quot;likely free&quot;.</p>
<h3 id="should-i-buffer-in-degrees-or-metres-in-postgis"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#should-i-buffer-in-degrees-or-metres-in-postgis">Should I buffer in degrees or metres in PostGIS?</a></h3>
<p>Metres. On EPSG:4326 geometry, distances are in degrees, and a degree of
longitude shrinks with latitude: 0.00025° is 27.8 m north–south but 20.8 m
east–west in Barcelona. Cast to <code>geography</code> and <code>ST_DWithin</code> takes metres.</p>
<h3 id="how-do-i-draw-thousands-of-polylines-in-a-swiftui-map-without-lag"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#how-do-i-draw-thousands-of-polylines-in-a-swiftui-map-without-lag">How do I draw thousands of polylines in a SwiftUI Map without lag?</a></h3>
<p>SwiftUI&#39;s <code>Map</code> needs one <code>MapPolyline</code> per line and can&#39;t take an
<code>MKMultiPolyline</code>, so the line count is the cost. Fetch a padded area, skip
requests while the viewport stays inside it, debounce camera changes by about
250 ms, draw only what&#39;s visible and send only the fields you draw. Zoomed out,
show area counts instead.</p>
<h3 id="how-do-you-know-if-a-live-car-park-count-is-still-current"><a class="anchor" href="https://treamz.me/blog/free-parking-map-from-open-data#how-do-you-know-if-a-live-car-park-count-is-still-current">How do you know if a live car park count is still current?</a></h3>
<p>Compare its timestamp with the server&#39;s clock. Easy Find Parking treats a count
older than 15 minutes as stale: it keeps the number but shows it as &quot;free when
last seen&quot; with its age, never as spaces available now.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Offline-first aquarium log in Flutter with Drift and Supabase</title>
      <link>https://treamz.me/blog/aquarium-log-flutter-drift-supabase</link>
      <guid isPermaLink="true">https://treamz.me/blog/aquarium-log-flutter-drift-supabase</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>How I modelled water tests in Flutter: blank readings that never become zero, free ammonia from pH and temperature, canonical units, and Drift-to-Supabase sync.</description>
      <category>Flutter</category>
      <category>Dart</category>
      <category>Drift</category>
      <category>Supabase</category>
      <category>Offline First</category>
      <content:encoded><![CDATA[<p>This post is about the data model behind my aquarium app: readings where a
blank field never turns into a zero, free ammonia worked out from pH and
temperature, one stored unit per parameter, and a local Drift database that
syncs to Supabase through an outbox. The app is
<a href="https://apps.apple.com/us/app/aquarium-log-tracker/id6804384133">Aquarium - Log Tracker</a>,
a free iPhone logbook for water tests, written in Flutter. What it does for
fishkeepers is in <a href="https://treamz.me/blog/aquarium-water-test-log-app">the user-facing post</a>;
this one is the code.</p>
<h2 id="blank-is-not-zero"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#blank-is-not-zero">Blank is not zero</a></h2>
<p>A water test is a handful of numbers typed standing at a tank, often with wet
hands, and nobody tests everything every time. So the first real decision was
what an empty field means. The answer is nothing. There is no nullable value
column: a parameter you didn&#39;t test produces no row at all.</p>
<div class="code" data-lang="dart"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">final</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> setting </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">in</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> settings) {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  final</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> text </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> _controllers[setting.key]</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">?</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.text.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">trim</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">() </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">??</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4"> ''</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">;</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">  // An untouched field produces no reading at all. Never zero.</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  if</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (text.isEmpty) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">continue</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  final</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> value </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> double.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">tryParse</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(text.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">replaceAll</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">','</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'.'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">));</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  if</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (value </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">==</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> null</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    setState</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(() </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> _error </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4"> '</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">${setting.definition.displayName}</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">: "</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">$text</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">" is not a number'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">);</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">;</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">  }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">  entries.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">add</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(ReadingDraft(parameterKey</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> setting.key, value</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> value,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">      unit</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> _units[setting.key] </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">??</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ref.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">read</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(unitsProvider).</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">unitFor</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(setting.definition)));</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p>The previous value sits in each field as <code>hintText</code>, not as text, so it&#39;s a
reference you can&#39;t save by accident.</p>
<p>This matters most for rate of change. Each saved reading is compared with the
latest reading of the same parameter, which is by construction the last time
that thing was actually measured. The default reef rules allow alkalinity to
move 0.5 dKH a day. Store a skipped test as zero and the next honest 8.4 dKH
looks like the tank jumped 8.4 dKH overnight. Two related rules: no daily rate
from readings less than 12 hours apart, because over an hour the difference is
mostly the test kit, and no pH drift limit for planted tanks, where CO₂
injection swings pH every day on purpose.</p>
<p>Charts follow from the same model. Each parameter gets its own row and scale
on a shared time axis, with points spaced by date and a dot on every real
measurement. A month without a nitrate test is a long straight segment between
two dots, not a dip to zero. That segment is still an interpolation the eye
can read as data; making the dots obvious is the best answer I have.</p>
<h2 id="absence-of-a-measurement-is-not-a-measurement"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#absence-of-a-measurement-is-not-a-measurement">Absence of a measurement is not a measurement</a></h2>
<p>Nulls do appear in one place: when rows are folded into per-session snapshots
to work out where a new tank is in its nitrogen cycle. A snapshot holds
nullable ammonia, nitrite, nitrate, pH, KH and temperature, and a null never
counts as clear or present.</p>
<div class="code" data-lang="dart"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">bool </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">get</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ammoniaClear </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ammonia </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!=</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> null</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> &#x26;&#x26;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ammonia</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> &#x3C;=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> Cycling.effectivelyZero;</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">bool </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">get</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> nitriteClear </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> nitrite </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!=</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> null</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> &#x26;&#x26;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> nitrite</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> &#x3C;=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> Cycling.effectivelyZero;</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">bool </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">get</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> nitratePresent </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> nitrate </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!=</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> null</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> &#x26;&#x26;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> nitrate</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> ></span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">bool </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">get</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> passesCompletionTest </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ammoniaClear </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">&#x26;&#x26;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> nitriteClear </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">&#x26;&#x26;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> nitratePresent;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// In Cycling.assess: the stage comes from the newest session that measured</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// ammonia or nitrite, not simply the newest session.</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">final</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> latest </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> sorted.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">lastWhere</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">  (s) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> s.ammonia </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!=</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> null</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> ||</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> s.nitrite </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!=</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> null</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">  orElse</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> () </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> sorted.last,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">);</span></span></code></pre></div>
<p>A tank counts as cycled after two passing sessions in a row, because one clean
result can be a misread. Nitrate is required so that &quot;nothing was ever dosed&quot;
can&#39;t pass for &quot;the bacteria are working&quot;. &quot;Clear&quot; means at or below 0.25 ppm,
because my research notes flag a common hobby kit as reading about that on
clean water.</p>
<p>The <code>lastWhere</code> exists because of a bug I found by looking at the screen, not
with a test. After logging a single alkalinity reading, the cycling banner
dropped back to &quot;Setting up&quot;: the newest snapshot had no ammonia in it, and
the code took that as the cycle going backwards. Stall detection had the other
half of the bug. KH and pH are often tested on a different day from ammonia,
so each check now uses the latest session that measured that particular thing.</p>
<h2 id="free-ammonia-from-ph-and-temperature"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#free-ammonia-from-ph-and-temperature">Free ammonia from pH and temperature</a></h2>
<p>Kits report total ammonia. The seriously toxic part is un-ionised NH₃, and its
share depends on pH and temperature, as the
<a href="https://www.epa.gov/caddis/ammonia">EPA&#39;s ammonia page</a> explains. When the
user has typed all three into the same session, the rest is arithmetic.</p>
<div class="code" data-lang="dart"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">/// pKa = 0.09018 + 2729.92 / T(K). At 25 C this gives ~9.25.</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">static</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> double </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">pKa</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(double temperatureC) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    0.09018</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> +</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 2729.92</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> /</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (temperatureC </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">+</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 273.15</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">/// Fraction of total ammonia present as toxic, un-ionised NH3 (0..1).</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">static</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> double </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">freeFraction</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">({</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">required</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> double ph, </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">required</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> double temperatureC}) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">    1</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> /</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">1</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> +</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> math.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">pow</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">10</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">pKa</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(temperatureC) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">-</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ph));</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">static</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> double </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">freeAmmoniaPpm</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">({</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  required</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> double totalAmmoniaPpm,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  required</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> double ph,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  required</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> double temperatureC,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> totalAmmoniaPpm </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">*</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> freeFraction</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(ph</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ph, temperatureC</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> temperatureC);</span></span></code></pre></div>
<p>The pKa expression is the same as in the Florida Department of Environmental
Protection&#39;s
<a href="https://floridadep.gov/sites/default/files/unnh3sop.pdf">procedure for calculating un-ionized ammonia</a>,
which writes the constant as 0.0901821, credits Thurston et al., and gives it
as valid from 0 to 50 °C and pH 6 to 10. At 25 °C it puts about 0.56% of total
ammonia in the free form at pH 7 and 5.4% at pH 8, matching the table in that
document. One test pins down why
the feature exists: 0.5 ppm total ammonia is 0.0017 ppm free at pH 6.8 and
24 °C, and 0.054 ppm at pH 8.3 and 26 °C. Same kit reading, about 33 times
apart.</p>
<p>The result appears only when the saved session has ammonia, pH and temperature
together; it doesn&#39;t borrow last week&#39;s pH. The known limitation: the
expression is for fresh water. The same document says the free fraction drops
slightly as ionic strength rises and gives a separate correction for saline
water, which the app doesn&#39;t apply, so in a reef tank it errs high. It
calculates; it doesn&#39;t diagnose.</p>
<h2 id="one-stored-unit-per-parameter"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#one-stored-unit-per-parameter">One stored unit per parameter</a></h2>
<p>Each reading keeps the value in the parameter&#39;s canonical unit and, beside it,
exactly what the user typed and in which unit, so a conversion bug can&#39;t
destroy the original observation. Nitrate is stored as the NO₃ ion, alkalinity
in meq/L, salinity in ppt, temperature in °C. The display unit is a device
setting, since it&#39;s about how this person reads their kit, not a fact about
the tank. Grading uses the canonical value, so a reading in range stays in
range whatever it&#39;s shown as.</p>
<p>The ratios are where silent errors live. NO₃ is 4.427 times NO₃-N, so the same
water reads 10 on one kit and 44 on another. Nitrite has its own ratio, 3.284.
One meq/L is 2.8 dKH. An unknown unit throws instead of passing the number
through, because an unconverted alkalinity is off by 2.8 times and still looks
plausible on a chart. I caught myself quoting nitrate&#39;s 4.4 on the nitrite row,
on a screen whose whole job is preventing unit mistakes. Both figures now come from
constants pinned by tests.</p>
<h2 id="drift-as-the-source-of-truth-with-an-outbox"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#drift-as-the-source-of-truth-with-an-outbox">Drift as the source of truth, with an outbox</a></h2>
<p>The UI reads only from Drift. Every write goes into its table and into an
outbox table in the same transaction, so nothing appears on screen without
also being queued for the server.</p>
<div class="code" data-lang="dart"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">await</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> _db.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">transaction</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(() </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">async</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">  // ...the session row and its outbox entry first, then for each reading:</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  await</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> _db.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">into</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(_db.readings).</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">insert</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(ReadingsCompanion.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">insert</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    id</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> id,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    tankId</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> draft.tankId,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    sessionId</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> Value(sessionId),</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    parameterKey</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> entry.parameterKey,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    valueCanonical</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> canonical,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    enteredValue</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> entry.value, enteredUnit</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> entry.unit,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    measuredAt</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> draft.measuredAt,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    updatedAt</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> stamp,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">  ));</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  await</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> _outbox.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">enqueue</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(entity</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> SyncEntity.readings, entityId</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> id, op</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> SyncOp.upsert,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">      payload</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">/* the same row as JSON */</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">});</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">});</span></span></code></pre></div>
<p>The outbox stores the payload rather than re-reading the row later, so an
edit can&#39;t change what an earlier queued write meant. IDs are UUID v7 made on
the device, so a reading can reference a tank that hasn&#39;t synced yet. Deletes
set <code>deleted_at</code>, because a delta sync can&#39;t express a row that stopped
existing.</p>
<p>The sync engine is written against a small <code>SyncRemote</code> interface and tested
against a fake, on an in-memory SQLite database rather than a mock. It pushes
before it pulls, parents before children. The pull asks for rows with
<code>updated_at</code> after the stored cursor minus two seconds, because a transaction
that began before the cursor can commit after it. The cursor comes from server
rows, stamped by a Postgres trigger, never from the device clock, and a pulled
row is skipped while the same row has an unpushed local edit. Drift
<a href="https://drift.simonbinder.eu/guides/datetime-migrations/">reads DateTimes back in local time</a>
by default, so I set <code>store_date_time_values_as_text</code>, which keeps UTC as UTC.</p>
<p>What went wrong:</p>
<ul>
<li>Against a project that took fourteen seconds to answer auth, eight changes
were dead-lettered and shown as refused by the server. They hadn&#39;t been.
Each timeout counted as an attempt across launches and resumes, and after
five the tank was abandoned; its parameter settings then pointed at a tank
the server had never seen, row level security refused them, and they went
too. Now being offline never uses up attempts, a pass stops before anything
depending on an abandoned row, and dead letters have a &quot;Try again&quot;.</li>
<li>Sync only ran while the account screen was open, because only that screen
watched the provider. It&#39;s now watched at the app root and invalidated on
resume.</li>
<li>The tank screen kept showing &quot;not tested yet&quot; after a test was logged,
until a restart. The query watched only the settings table but attached the
last reading from another one; Drift&#39;s
<a href="https://drift.simonbinder.eu/sql_api/custom_queries/"><code>readsFrom</code></a> fixed
it. The tests missed it because they called <code>.first</code> after writing. The new
one subscribes first.</li>
</ul>
<h2 id="row-level-security-briefly"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#row-level-security-briefly">Row level security, briefly</a></h2>
<p>Supabase only holds a copy for people who sign in, and row level security is
the real boundary; the publishable key in the app authorises nothing on its
own. Tanks carry an <code>owner_id</code>, and a <code>tank_members</code> table that every policy
already reads through means sharing can come later without a migration. Every
child table carries <code>tank_id</code>, so one pair of policies covers all of them:</p>
<div class="code" data-lang="sql"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">do $$</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">declare</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> t </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">text</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">;</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">begin</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">  foreach t </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">in</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> array</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> array</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">[</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    'tank_parameter_config','test_sessions','readings','livestock',</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    'task_templates','task_events','journal_entries','photos','cycle_logs'</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">  ] </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">loop</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    execute</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> format</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">($f$</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">      create</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> policy</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> %</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">1</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">$s_select </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">on</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> public.%</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">1</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">$s</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">      for</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> select</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> to</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> authenticated</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">      using</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (tank_id </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> any (</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">array</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">select</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> private.accessible_tank_ids())))</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    $f$, t);</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">    -- plus a matching %1$s_write policy using private.can_edit_tank(tank_id)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">  end</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> loop</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">;</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">end</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> $$;</span></span></code></pre></div>
<p>The helpers are <code>security definer</code> functions in a <code>private</code> schema with an
empty <code>search_path</code>, revoked from the API roles, which also breaks the
infinite recursion of a <code>tank_members</code> policy that queries <code>tank_members</code>.
<code>auth.uid()</code> is wrapped as <code>(select auth.uid())</code>, as the
<a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase RLS docs</a>
recommend. A pgTAP suite simulates two users by setting <code>request.jwt.claims</code>,
and an integration test runs the real sync code against a local Supabase stack
and checks that a second account sees none of it.</p>
<p>What the app does for the people who use it: <a href="https://treamz.me/blog/aquarium-water-test-log-app">A free aquarium water test log app for iPhone</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#faq">FAQ</a></h2>
<h3 id="how-do-you-calculate-free-ammonia-from-ph-and-temperature"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#how-do-you-calculate-free-ammonia-from-ph-and-temperature">How do you calculate free ammonia from pH and temperature?</a></h3>
<p>Compute pKa = 0.09018 + 2729.92 / T with T in kelvin, then the free fraction
= 1 / (1 + 10^(pKa − pH)), and multiply total ammonia by it. At 25 °C pKa is
about 9.25, so roughly 0.56% of total ammonia is un-ionised NH₃ at pH 7 and
5.4% at pH 8. The expression is for fresh water; in seawater the real fraction
is slightly lower.</p>
<h3 id="should-an-untested-value-be-stored-as-null-or-zero"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#should-an-untested-value-be-stored-as-null-or-zero">Should an untested value be stored as null or zero?</a></h3>
<p>Neither. In Aquarium an untested parameter produces no reading row, and nulls
appear only when rows are grouped into per-session views. Storing zero for
&quot;not tested&quot; draws dips that never happened and makes rate-of-change checks
compare against a value nobody measured.</p>
<h3 id="how-do-you-build-offline-first-sync-with-drift-and-supabase"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#how-do-you-build-offline-first-sync-with-drift-and-supabase">How do you build offline-first sync with Drift and Supabase?</a></h3>
<p>Make Drift the only thing the UI reads, and write every change to its table
and to an outbox table in one Drift transaction. A sync engine pushes the
outbox parents-first, then pulls rows whose server-stamped <code>updated_at</code> is
newer than the last one it saw minus a short overlap, skipping rows with
unpushed local edits. Client-generated UUIDs and <code>deleted_at</code> tombstones let
both sides agree on what changed.</p>
<h3 id="how-does-an-app-decide-an-aquarium-is-cycled"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#how-does-an-app-decide-an-aquarium-is-cycled">How does an app decide an aquarium is cycled?</a></h3>
<p>Aquarium groups readings into test sessions and looks for two sessions in a
row where ammonia and nitrite are at or below 0.25 ppm and nitrate is present.
One such session is shown as &quot;confirming&quot;, because a single clean result can
be a misread.</p>
<h3 id="how-do-you-write-supabase-rls-policies-for-tables-that-belong-to-a-parent-row"><a class="anchor" href="https://treamz.me/blog/aquarium-log-flutter-drift-supabase#how-do-you-write-supabase-rls-policies-for-tables-that-belong-to-a-parent-row">How do you write Supabase RLS policies for tables that belong to a parent row?</a></h3>
<p>Give every child table the parent&#39;s id, put the access check in a
<code>security definer</code> function in a schema the API can&#39;t reach, and generate the
same select and write policy for each table in a loop. Wrap <code>auth.uid()</code> as
<code>(select auth.uid())</code> and index the columns the policies filter on.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Pet health record app: vaccinations, vet visits, reminders</title>
      <link>https://treamz.me/blog/pet-health-records-app</link>
      <guid isPermaLink="true">https://treamz.me/blog/pet-health-records-app</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>PawVault is a free iPhone and iPad app for your pet&apos;s health records: vaccinations, vet visits, medications and documents, with reminders and a vet PDF.</description>
      <category>iOS</category>
      <category>Flutter</category>
      <category>Pet health records</category>
      <category>Vaccination tracker</category>
      <category>Vet records</category>
      <content:encoded><![CDATA[<p>PawVault is an app for keeping a pet&#39;s health records in one place: vaccinations, vet visits, medications, lab results and the certificates that prove them, with reminders for the next booster, flea treatment or dose. It&#39;s for dog and cat owners who are tired of searching email and the camera roll when a vet, a kennel or a border asks for proof. It runs on iPhone and iPad, and on Apple silicon Macs and Apple Vision Pro as the iPad app, and it&#39;s free to download on the <a href="https://apps.apple.com/us/app/pawvault-pet-health-records/id6779953779">App Store</a>.</p>
<p>I built it, so this is how it works and where it stops.</p>
<h2 id="why-your-pets-vet-records-are-never-where-you-need-them"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#why-your-pets-vet-records-are-never-where-you-need-them">Why your pet&#39;s vet records are never where you need them</a></h2>
<p>A rabies certificate in a drawer. A lab report in email. A prescription label in the camera roll. A second clinic, because the emergency happened on a Sunday. None of it is findable at the counter of a kennel that wants proof of vaccination. PawVault is one record per pet that opens in seconds and becomes a PDF when someone needs a copy.</p>
<h2 id="what-pawvault-keeps-for-each-pet"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#what-pawvault-keeps-for-each-pet">What PawVault keeps for each pet</a></h2>
<ul>
<li><strong>A profile:</strong> species, breed, sex, date of birth, weight, microchip number, allergies, chronic conditions and notes.</li>
<li><strong>A health timeline:</strong> vaccinations, vet visits, medications, lab tests, surgeries, symptoms, grooming, food and allergies, filterable by type or date.</li>
<li><strong>A document vault:</strong> vaccination certificates, pet passports, insurance, lab results, prescriptions, vet reports and receipts, as photos or PDFs, each with its issue date and an expiry date if it has one. Expired documents are marked in red.</li>
<li><strong>Reminders,</strong> covered below.</li>
</ul>
<figure><img src="https://treamz.me/blog/pet-health-records-app/documents.jpg" alt="PawVault documents screen listing a rabies vaccination certificate that expires May 2, 2029, blood panel results and a pet insurance policy that expires Jan 1, 2027" width="644" height="1400" loading="lazy" decoding="async"><figcaption>The document vault, with issue and expiry dates on each entry</figcaption></figure>
<h2 id="how-scanning-a-vaccination-certificate-or-prescription-works"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#how-scanning-a-vaccination-certificate-or-prescription-works">How scanning a vaccination certificate or prescription works</a></h2>
<p>Photograph the document or pick a PDF. The app sends the pages to Google&#39;s Gemini model (Gemini 2.5 Flash, through Firebase AI Logic) and gets back a draft: document type, title, issue date, a short summary, and an expiry date only when the paper prints one, such as &quot;valid until&quot; or &quot;booster due&quot;. The model is told never to guess a date.</p>
<p>You correct the draft and save; until then nothing is stored, not even the photo. Smart Input does the same for typed notes: &quot;gave Luna her flea tablet today, next one on the 5th&quot; becomes a draft timeline entry and, where it fits, a suggested reminder.</p>
<p>Both are part of PawVault Pro, along with Paw Scan, which describes what&#39;s visible in a photo of a paw and stops short of saying what anything is. Adding documents by hand is free.</p>
<h2 id="reminders-for-vaccines-flea-and-tick-treatment-and-medication"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#reminders-for-vaccines-flea-and-tick-treatment-and-medication">Reminders for vaccines, flea and tick treatment and medication</a></h2>
<p>A reminder has a date, a time and an optional repeat: daily, weekly, monthly, yearly, or every so many days. iOS delivers them as notifications scheduled on the device, and anything past due shows in red until you tick it off.</p>
<p>It fires at the time you set, not days early, so pick the day you want to book, not the day a vaccine runs out. How often a vaccine or parasite treatment is due is your vet&#39;s call; PawVault remembers the date you give it and never suggests one.</p>
<figure><img src="https://treamz.me/blog/pet-health-records-app/reminders.jpg" alt="PawVault reminders screen with a monthly flea and tick treatment shown in red as overdue, a yearly wellness exam and a yearly rabies booster" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Repeating reminders; an overdue one stays red until it&#39;s ticked off</figcaption></figure>
<h2 id="sharing-records-with-a-vet-a-sitter-or-a-new-clinic"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#sharing-records-with-a-vet-a-sitter-or-a-new-clinic">Sharing records with a vet, a sitter or a new clinic</a></h2>
<p>Vet Summary turns a pet&#39;s record into a PDF: profile with microchip number, an overview of allergies, conditions, recent symptoms and vaccinations, the timeline, every document with its dates, and the reminders. It&#39;s labelled as owner-entered records. Send it by AirDrop, Mail or Messages, or save it to Files.</p>
<p>The PDF lists documents but doesn&#39;t embed the scans; for the actual certificate, open it in the app.</p>
<h2 id="multiple-pets-and-more-than-one-device"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#multiple-pets-and-more-than-one-device">Multiple pets and more than one device</a></h2>
<p>The free version holds one pet. PawVault Pro removes the limit; it&#39;s a yearly subscription, listed at $19.99 a year on the US App Store, with a free trial.</p>
<p>To use the same records on another iPhone, an iPad or a Mac, sign in with email, Google or Apple on each. Whatever you entered before signing in is linked to the account. Sync runs through Firebase, not iCloud.</p>
<h2 id="where-the-records-are-stored"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#where-the-records-are-stored">Where the records are stored</a></h2>
<ul>
<li><strong>No sign-up to start.</strong> The app creates an anonymous Firebase account on first launch and saves records under it.</li>
<li><strong>Records</strong> live in Cloud Firestore and <strong>files</strong> (scans, photos, saved PDFs) in Firebase Storage. Access rules let only the signed-in account, anonymous or not, read or write its own data.</li>
<li><strong>Not end-to-end encrypted.</strong> Data travels encrypted, but it&#39;s protected by those rules, not by a key only you hold.</li>
<li><strong>AI only when you ask.</strong> The text or images you chose go to Gemini when you start a scan or Smart Input.</li>
<li><strong>Analytics count feature use.</strong> Pet names, notes and document text are never sent. RevenueCat, which runs the subscription, gets only an account ID.</li>
<li><strong>Deletion.</strong> Signed in, you can delete your account from the account menu, which removes every pet, document, reminder and file.</li>
</ul>
<h2 id="who-its-for-and-who-it-isnt"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#who-its-for-and-who-it-isnt">Who it&#39;s for, and who it isn&#39;t</a></h2>
<p>It&#39;s for owners dealing with more than one place: two clinics, a kennel that asks for papers, a move, a trip.</p>
<p>It isn&#39;t for you if:</p>
<ul>
<li>you want records that stay only on your phone.</li>
<li>you want advice. It doesn&#39;t diagnose, recommend treatment or plan vaccinations.</li>
<li>you expect it to pull records from your clinic. It holds what you enter or scan.</li>
<li>you need a language other than English.</li>
<li>you need household sharing with separate accounts.</li>
</ul>
<h2 id="how-to-get-started"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#how-to-get-started">How to get started</a></h2>
<ol>
<li>Install <a href="https://apps.apple.com/us/app/pawvault-pet-health-records/id6779953779">PawVault from the App Store</a>.</li>
<li>Add your pet, including the microchip number and allergies.</li>
<li>Photograph each certificate you have, or pick the PDF, and set the expiry date where one is printed.</li>
<li>Log the last vaccination and current medication on the timeline.</li>
<li>Set reminders for the next booster, flea and tick treatment and doses.</li>
<li>Sign in if you want a backup or a second device.</li>
<li>Before a vet visit or a trip, generate the Vet Summary PDF.</li>
</ol>
<p>How it compares with PetDesk, VitusVet, Digitail, GreatPetCare and others: <a href="https://treamz.me/blog/pet-health-record-apps-compared">Pet health record apps for iPhone: 7 compared</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#faq">FAQ</a></h2>
<h3 id="whats-the-best-way-to-keep-track-of-my-dogs-vaccinations"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#whats-the-best-way-to-keep-track-of-my-dogs-vaccinations">What&#39;s the best way to keep track of my dog&#39;s vaccinations?</a></h3>
<p>Keep the certificates in one place, note when each vaccine was given and when your vet says the next is due, and set a reminder before that date. An app such as PawVault stores the certificates as photos or PDFs, keeps the dates on a timeline and sends a notification on the day you choose.</p>
<h3 id="where-should-i-keep-my-cats-vet-records"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#where-should-i-keep-my-cats-vet-records">Where should I keep my cat&#39;s vet records?</a></h3>
<p>Somewhere you can reach from your phone, because the request usually comes at a counter. PawVault keeps each cat&#39;s profile, vet visits, medications and documents together on iPhone and iPad, and exports them as one PDF.</p>
<h3 id="is-there-an-app-that-reminds-me-about-pet-vaccines-and-flea-treatment"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#is-there-an-app-that-reminds-me-about-pet-vaccines-and-flea-treatment">Is there an app that reminds me about pet vaccines and flea treatment?</a></h3>
<p>Yes. PawVault sets one-off or repeating reminders for vaccines, flea and tick treatment and medication, delivered as notifications on iPhone or iPad. The dates come from you and your vet; the app doesn&#39;t suggest a schedule.</p>
<h3 id="what-should-i-bring-to-a-new-vet-or-when-traveling-with-my-pet"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#what-should-i-bring-to-a-new-vet-or-when-traveling-with-my-pet">What should I bring to a new vet or when traveling with my pet?</a></h3>
<p>Bring vaccination certificates (rabies in particular), current medications and doses, allergies and conditions, the microchip number and recent lab results. Travel rules depend on the destination; for the US, the <a href="https://www.aphis.usda.gov/pet-travel">USDA APHIS pet travel pages</a> explain them. PawVault&#39;s Vet Summary PDF puts the profile, timeline, document list and reminders in one file.</p>
<h3 id="is-pawvault-free"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#is-pawvault-free">Is PawVault free?</a></h3>
<p>PawVault is free to download, and the free version covers the timeline, documents, reminders and vet PDF for one pet. PawVault Pro adds unlimited pets and the AI features (document scanning, Smart Input and Paw Scan) for $19.99 a year on the US App Store, with a free trial.</p>
<h3 id="can-pawvault-scan-a-vaccination-certificate"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#can-pawvault-scan-a-vaccination-certificate">Can PawVault scan a vaccination certificate?</a></h3>
<p>Yes, with PawVault Pro. Photograph the certificate or pick a PDF, and Google&#39;s Gemini model fills in the document type, title, issue date and, when printed, the expiry date. Nothing is saved until you review and confirm the draft.</p>
<h3 id="where-does-pawvault-store-my-pets-records-and-does-it-use-icloud"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#where-does-pawvault-store-my-pets-records-and-does-it-use-icloud">Where does PawVault store my pet&#39;s records, and does it use iCloud?</a></h3>
<p>In Google Firebase, not iCloud: Cloud Firestore for records and Firebase Storage for files, under rules that let only your account read them. It works without signing up by creating an anonymous account; signing in with email, Google or Apple backs the records up and syncs them across devices.</p>
<h3 id="does-pawvault-give-medical-advice-or-vaccine-schedules"><a class="anchor" href="https://treamz.me/blog/pet-health-records-app#does-pawvault-give-medical-advice-or-vaccine-schedules">Does PawVault give medical advice or vaccine schedules?</a></h3>
<p>No. PawVault keeps records and sends reminders; it doesn&#39;t diagnose, recommend treatment or set vaccination schedules. Your vet decides those.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Planning sourdough backwards in Swift, with quiet hours</title>
      <link>https://treamz.me/blog/planning-sourdough-backwards-in-swift</link>
      <guid isPermaLink="true">https://treamz.me/blog/planning-sourdough-backwards-in-swift</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>How Proofday plans a sourdough bake backwards in Swift: a temperature model, a dynamic program on a 5-minute grid, quiet hours and Live Activity limits.</description>
      <category>iOS</category>
      <category>Swift</category>
      <category>Algorithms</category>
      <category>Scheduling</category>
      <category>Live Activities</category>
      <content:encoded><![CDATA[<p>Proofday is an iPhone sourdough planner: you say when you want bread, and it
works out when to feed the starter, mix, fold, shape and bake, adjusted for
kitchen temperature and kept out of the hours you sleep. It&#39;s
<a href="https://apps.apple.com/us/app/proofday-sourdough-planner/id6826169140">on the App Store</a>,
and <a href="https://treamz.me/blog/sourdough-schedule-planner-app">the post for bakers</a>
covers what it does. This one is about how: the fermentation model, the dynamic
program that plans backwards, how it refuses an impossible time, and the iOS
limits that shaped the reminders.</p>
<p>The plans below come from running the app&#39;s planning package, a Swift package
with no UI, on its Beginner Country Loaf recipe.</p>
<h2 id="the-fermentation-model-is-one-formula"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#the-fermentation-model-is-one-formula">The fermentation model is one formula</a></h2>
<p>Proofday&#39;s model says every 7 °C doubles or halves fermentation time, relative
to 24 °C:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// FermentationConfig.swift</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">public</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> var</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> referenceTemperatureC: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Double</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> =</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 24</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">public</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> var</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> degreesPerDoubling: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Double</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> =</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 7</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">public</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> var</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> trustedTemperatureRangeC: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">ClosedRange</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">&#x3C;</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Double</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">> </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 16</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">...</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">30</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// FermentationModel.swift</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">public</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> func</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> temperatureFactor</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">celsius</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Double</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">-></span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> Double</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> t </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> clamp</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">temperatureC</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: celsius).value</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> pow</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">2</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, (config.referenceTemperatureC </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">-</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> t) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">/</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> config.degreesPerDoubling)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p>Bulk is 5 hours at 24 °C with 20% levain, so 18 °C gives a factor of 1.81 and
about 9 hours, and 30 °C gives 0.55 and 2.8 hours. Outside 16–30 °C the input is
clamped and the plan carries a warning, so 34 °C plans exactly like 30 °C
rather than trusting an exponential that far out.</p>
<p>The model&#39;s other constants, down to the starter peak table (5 to 14 hours at
24 °C, from 1:1:1 to 1:10:10 feeds), live in the same struct. Its doc comment
calls them &quot;starting heuristics, not lab science&quot;.</p>
<h2 id="learning-from-logged-bakes"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#learning-from-logged-bakes">Learning from logged bakes</a></h2>
<p>When a starter peaks or a bulk ends, the app folds actual over predicted into a
per-starter moving average:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">public</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> mutating</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> func</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> record</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">actual</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: TimeInterval, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">baselinePrediction</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: TimeInterval,</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">                            config</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: FermentationConfig </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .standard) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">-></span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> Bool</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    guard</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> actual </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">></span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, baselinePrediction </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">></span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> false</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ratio </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> actual </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">/</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> baselinePrediction</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    guard</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> config.correctionSampleSanityRange.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">contains</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(ratio) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> false</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> alpha </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> config.correctionSmoothing</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> next </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> alpha </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">*</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> ratio </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">+</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">1</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> -</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> alpha) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">*</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> value</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    value </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> min</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">max</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(next, config.correctionRange.lowerBound), config.correctionRange.upperBound)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    sampleCount </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">+=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 1</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    return</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> true</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p>α is 0.3, the factor stays within 0.6–1.6, and a ratio outside 0.25–4 counts as
a logging mistake. The part that matters is the argument name. Predictions are
already multiplied by the factor, so a sample has to be compared with the
<em>uncorrected</em> baseline. Compare with the corrected one and the average settles
at the square root of the true factor: a starter that really takes 1.44 times as
long gets learned as 1.2. So each feeding and each plan stores its uncorrected
estimate up front.</p>
<h2 id="planning-backwards-is-a-shortest-path"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#planning-backwards-is-a-shortest-path">Planning backwards is a shortest path</a></h2>
<p>A recipe is a chain of typed steps. Each resolves into a minimum, ideal and
maximum length, hands-on minutes, and the moments the baker acts: the step&#39;s
start, each fold, and the oven preheat as a negative offset from the bake. Bulk
doesn&#39;t flex at plan time; the aliquot jar decides when it really ends. The
cold proof (8 to 24 hours), autolyse, bench rest, cooling and the levain do.</p>
<p>Rounding every length to a 5-minute grid turns the search into a dynamic
program. The state is &quot;step i starts at time t&quot;, filled in from the ready time
backwards:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> i </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">in</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> stride</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">from</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: n </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">-</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 1</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">through</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">by</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">-1</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    var</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> next: [</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Int</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: Node] </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> [</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">]</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> t </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">in</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> levels[i </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">+</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 1</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">].keys.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">sorted</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">by</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">        let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> node </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> levels[i </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">+</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 1</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">][t]</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">!</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">        for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> (vi, variant) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">in</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> steps[i].</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">enumerated</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">() {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">            let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> s </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> t </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">-</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> variant.duration</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">            if</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> s </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">&#x3C;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> earliestStart { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">continue</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">            let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> v </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> violations</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">of</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: variant, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">at</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: s, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">anchorExempt</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">false</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">            if</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> hard </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">&#x26;&#x26;</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> v </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">></span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">continue</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">            let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> cost </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> node.cost </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">+</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> variant.cost </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">+</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> Double</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(v) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">*</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> violationCost</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">            if</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> existing </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> next[s], existing.cost </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">&#x3C;=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> cost { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">continue</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">            next[s] </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> Node</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">cost</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: cost, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">violations</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: node.violations </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">+</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> v, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">link</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: t, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">variant</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: vi)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">        }</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    if</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> next.isEmpty { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> nil</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    levels[i] </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> next</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p><code>levels[n]</code> holds the ready time at cost zero. A variant is one layout of a
step: a length, plus a feed ratio for the levain or a preheat length for the
bake. Keeping only the cheapest path into each boundary is exact because a
step&#39;s cost and quiet-hours check depend only on its own start and length. On a
tie the later start wins, so you wait less.</p>
<p>Quiet hours default to 23:00–07:00, and staying out of them has two shapes. An
action is a point and may sit on the boundary, so a fold at 7:00 is fine. Hands-on minutes are a span and may not
overlap at all, so a 15-minute shape has to start by 22:45.</p>
<h2 id="preferences-are-just-prices"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#preferences-are-just-prices">Preferences are just prices</a></h2>
<p>There&#39;s no rule saying &quot;stretch the cold proof first&quot;. There are costs:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">public</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> var</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> costPerMinute: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Double</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    switch</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> self</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .coldProof</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0.05</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .levain</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0.15</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .autolyse, .rest</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0.2</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .cool, .roomProof</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0.3</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    case</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .benchRest</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 0.5</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    default:</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 1</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    }</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// FermentationConfig</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">public</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> var</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> feedRatioSwitchCost: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Double</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> =</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 20</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">public</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> var</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> quietHoursViolationCost: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">Double</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> =</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 10_000</span></span></code></pre></div>
<p>A cold proof minute away from the ideal is the cheapest thing on the list, a
third of a levain minute. A different feed ratio costs 20, as much as moving the
cold proof 400 minutes. So a plan bridges the night with the fridge when it can,
and reaches for a 1:5:5 or 1:10:10 overnight feed when that&#39;s cheaper.</p>
<p>For the Beginner Country Loaf at 23 °C, ready Saturday at 10:00, the 12 steps
expand to 182 variants, 70 of them for the levain alone:</p>
<div class="code"><pre class="shiki"><code>Fri 07:10  levain       465 min  1:2:2
Fri 14:55  autolyse      60 min
Fri 15:55  mix           15 min
Fri 16:10  bulk         330 min  folds 16:40, 17:10, 17:40, 18:10
Fri 21:40  preshape       5 min
Fri 21:45  bench rest    30 min
Fri 22:15  shape         15 min
Fri 22:30  cold proof   555 min  (ideal 720)
Sat 07:45  bake          20 min  preheat from 07:00
Sat 08:05  lid off       25 min
Sat 08:30  cool          90 min  (ideal 120)
Sat 10:00  ready</code></pre></div>
<p>The preheat can&#39;t start before 7:00, which pins the bake and cuts cooling to 90
minutes; the plan says so in a note. On my M1 Pro, in a release build, that
takes about a millisecond. The app still solves in a detached task, since the
setup screen re-plans on every input change.</p>
<p>Rough edges: windows longer than two hours are searched in 15-minute
steps, so the cold proof can&#39;t be 565 minutes and the levain starts at 7:10
rather than 7:00. And the cold proof used to stop at 16 hours, the figure I
started from, which made dinner-time bread impossible with the default quiet
hours. It now goes to 24: ready at 19:00 on Saturday means shaping at 22:45 on
Friday and a 17 h 15 min proof.</p>
<h2 id="saying-no-with-a-reason-and-a-better-time"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#saying-no-with-a-reason-and-a-better-time">Saying no, with a reason and a better time</a></h2>
<p>When the hard solve fails, the planner works out why, in order: the target has
passed, the step minimums don&#39;t fit, the target is inside quiet hours, or some
action would be. For that last case it reruns the DP in soft mode, where a
violation costs 10,000 instead of being forbidden, and reports the first action
that lands in quiet hours. Then it walks a 15-minute grid outward,
up to 48 hours each way, until a ready time solves.</p>
<p>From the package:</p>
<ul>
<li><strong>Saturday 9:00, asked on Thursday:</strong> the first violation is the preheat at
6:10 AM. Nearest: 9:30.</li>
<li><strong>Saturday 10:00, asked Friday at 8 PM:</strong> the bake needs at least 21 h 20 min.
Nearest: Sunday 9:30.</li>
<li><strong>Sunday 9:30, asked Saturday at noon:</strong> shaping would land at 11:30 PM.
Nearest: Monday 9:30. With &quot;My starter is already ripe&quot; on, Sunday 9:30
works, shaping at 7:30 PM.</li>
</ul>
<p>The third looked like a bug. It isn&#39;t: with a levain to build, the
earliest Saturday shape is about half an hour too late, and mixing on Sunday
morning puts the bread after 11 PM. These refusals took roughly 3 to 13 ms, mostly
the search for alternatives.</p>
<h2 id="re-planning-while-the-dough-is-out"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#re-planning-while-the-dough-is-out">Re-planning while the dough is out</a></h2>
<p>Done, +15 min and Need more time re-run the solver forwards from the current
step, in soft mode, with one more price: each minute the ready time drifts from
the promise costs 1, against 0.05 for a cold proof minute. In the plan above,
asking for 30 more minutes of bulk at 9:30 PM keeps 10:00 and shortens the cold
proof to 525 minutes. Asking for 45 can&#39;t be absorbed, because shaping would run
from 23:00 to 23:15. The plan keeps 10:00 and flags that action as inside quiet
hours rather than moving the bread.</p>
<h2 id="notifications-live-activities-and-widgets"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#notifications-live-activities-and-widgets">Notifications, Live Activities and widgets</a></h2>
<p>Every pending action is a local notification, rescheduled after any change.
The triggers are <code>UNTimeIntervalNotificationTrigger</code>, elapsed seconds rather
than calendar components, so a DST change can&#39;t move an absolute moment.
A time-zone change re-solves the plan, since quiet hours follow the wall clock.
Done, +15 min and Snooze 5 min carry no <code>.foreground</code> option, so
<a href="https://developer.apple.com/documentation/usernotifications/declaring-your-actionable-notification-types">iOS hands them to the app in the background</a>.</p>
<p>Live Activities were the awkward part. One
<a href="https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities">lasts up to eight hours, twelve on the Lock Screen</a>,
and <a href="https://developer.apple.com/documentation/activitykit/activity/request(attributes:content:pushtype:)"><code>Activity.request</code></a>
only works while the app is in the foreground. The Saturday plan runs almost 27
hours. So:</p>
<div class="code" data-lang="swift"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">static</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> startWindow: TimeInterval </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 7</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> *</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 3600</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">func</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> sync</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">planID</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: UUID, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">schedule</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: BakeSchedule, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">presenter</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: ActionPresenter, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">now</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: Date </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> .now) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">async</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    guard</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> !</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">schedule.isComplete, </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> state </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> Self</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">state</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">for</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: schedule, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">presenter</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: presenter, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">now</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: now) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> {</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">        await</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> end</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">planID</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: planID)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">        return</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    let</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> content </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> ActivityContent</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">state</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: state, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">staleDate</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: state.nextDate.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">addingTimeInterval</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">30</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> *</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 60</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">), </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">relevanceScore</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">100</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    if</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> await</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> Self</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">update</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">planID</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: planID, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">content</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: content) { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">    guard</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> ActivityAuthorizationInfo</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">().areActivitiesEnabled,</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">          state.nextDate.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">timeIntervalSince</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(now) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">&#x3C;</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> Self</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">.startWindow </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">else</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> { </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">return</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> }</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">    _ </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold"> try?</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> Activity.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">request</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">        attributes</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">BakeActivityAttributes</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">planID</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: planID, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">recipeName</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: schedule.recipeName),</span></span>
<span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">        content</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: content, </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">pushType</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">nil</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">}</span></span></code></pre></div>
<p><code>sync</code> runs after every plan change and whenever the app comes to the
foreground. It updates a running activity, or starts one when the next action is
under seven hours away, inside the eight-hour limit. There&#39;s no push server, so
the content changes only when the app&#39;s code runs, but the countdown keeps
moving because it&#39;s <code>Text(timerInterval:)</code>, drawn by the system. The catch,
stated in the user docs: it appears once you open the app within seven hours of
a step. iOS 26 added
<a href="https://developer.apple.com/documentation/activitykit/activity/request(attributes:content:pushtype:style:alertconfiguration:start:)">a <code>request</code> that takes a start date</a>
and starts the activity with the app in the background. Proofday supports iOS
17 and doesn&#39;t use it.</p>
<p>Its Done and +15 buttons are <code>LiveActivityIntent</code>s, which
<a href="https://developer.apple.com/documentation/appintents/liveactivityintent">run in the app&#39;s process</a>,
through a static bridge the app delegate fills at launch. Under Swift 6,
<code>Activity</code> isn&#39;t <code>Sendable</code>, so its handles stay inside <code>nonisolated</code> helpers.</p>
<p>Widgets never open SwiftData: the app writes a Codable snapshot of the next
seven actions to the App Group, and the timeline adds an entry as each comes
due, so the Next bake step widget advances on its own.</p>
<h2 id="app-attest-for-the-one-server-call"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#app-attest-for-the-one-server-call">App Attest for the one server call</a></h2>
<p>Crumb Check, a photo analysis of the cut loaf, is the only server feature: a
small proxy in front of a Claude model. Each install makes a key with
<code>DCAppAttestService</code> and has Apple attest it once. After that, every request carries an assertion over a single-use challenge plus
the SHA-256 of the body, and the server checks signature, counter and app ID
(<a href="https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity">Apple&#39;s overview</a>).
It allows 30 checks per install per day and stores only public keys,
challenges and counters.</p>
<p>What the app does for the people who use it: <a href="https://treamz.me/blog/sourdough-schedule-planner-app">Sourdough schedule planner that works back from bread time</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#faq">FAQ</a></h2>
<h3 id="how-do-you-schedule-tasks-backwards-from-a-deadline-with-time-windows"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#how-do-you-schedule-tasks-backwards-from-a-deadline-with-time-windows">How do you schedule tasks backwards from a deadline with time windows?</a></h3>
<p>Round durations to a grid and run a dynamic program from the deadline back:
the state is &quot;step i starts at time t&quot;, each step offers one choice per allowed
length, choices that put an action in a blocked window are dropped, and the
cheapest path into each boundary is kept. Proofday does this on a 5-minute grid.</p>
<h3 id="how-does-proofday-estimate-fermentation-time-at-different-temperatures"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#how-does-proofday-estimate-fermentation-time-at-different-temperatures">How does Proofday estimate fermentation time at different temperatures?</a></h3>
<p>It multiplies a duration at 24 °C by 2^((24 − T) / 7), so every 7 °C cooler
doubles it: a 5-hour bulk becomes about 9 hours at 18 °C. It&#39;s trusted between
16 and 30 °C, and a per-starter factor learned from logged bakes scales it.</p>
<h3 id="when-can-an-ios-app-start-a-live-activity"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#when-can-an-ios-app-start-a-live-activity">When can an iOS app start a Live Activity?</a></h3>
<p>With <code>Activity.request</code>, only while the app is in the foreground, or from a
<code>LiveActivityIntent</code>. From iOS 26 an app can also schedule one for a start
date. A Live Activity lasts up to eight hours, then can stay on the Lock Screen
for up to four more.</p>
<h3 id="how-do-you-keep-local-notifications-correct-across-daylight-saving-time"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#how-do-you-keep-local-notifications-correct-across-daylight-saving-time">How do you keep local notifications correct across daylight saving time?</a></h3>
<p>Use <code>UNTimeIntervalNotificationTrigger</code> with the seconds until the absolute
moment, not calendar components, so a clock change can&#39;t shift it. If times
follow the wall clock, re-plan and reschedule on <code>NSSystemTimeZoneDidChange</code>
and <code>UIApplication.significantTimeChangeNotification</code>.</p>
<h3 id="how-do-you-learn-a-correction-factor-from-logged-data-without-bias"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#how-do-you-learn-a-correction-factor-from-logged-data-without-bias">How do you learn a correction factor from logged data without bias?</a></h3>
<p>Average the ratio of actual duration to the uncorrected prediction and multiply
predictions by the result. Averaging against the already-corrected prediction
settles at the square root of the true factor. Proofday uses an exponential
moving average with weight 0.3, clamped to 0.6–1.6.</p>
<h3 id="what-should-a-scheduler-do-when-a-deadline-is-impossible"><a class="anchor" href="https://treamz.me/blog/planning-sourdough-backwards-in-swift#what-should-a-scheduler-do-when-a-deadline-is-impossible">What should a scheduler do when a deadline is impossible?</a></h3>
<p>Say why and offer the nearest time that works. Proofday finds the offending
action by re-running its solver with quiet-hour violations penalised instead of
forbidden, then searches a 15-minute grid up to 48 hours either side for the
closest feasible ready time.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Scan trading cards and see what they&apos;re worth with Foilcase</title>
      <link>https://treamz.me/blog/trading-card-scanner-app</link>
      <guid isPermaLink="true">https://treamz.me/blog/trading-card-scanner-app</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>Foilcase is an iPhone app that scans trading cards, identifies the exact printing, prices them daily and tracks what your collection is worth. No scan limits.</description>
      <category>iOS</category>
      <category>Trading cards</category>
      <category>Card scanner</category>
      <category>Collection tracking</category>
      <category>Swift</category>
      <content:encoded><![CDATA[<p>Foilcase is an iPhone app that scans trading cards, works out which printing you own, and tracks what your collection is worth with prices refreshed every day. It is for TCG collectors who keep singles, graded cards and sealed product and want a total they can trust. It is free to download on the <a href="https://apps.apple.com/us/app/foilcase-tcg-card-scanner/id6813665558">App Store</a>, both plans start with a 14-day free trial, and scanning is never metered: no per-card counter, no credits, no scan packs.</p>
<h2 id="why-the-exact-printing-decides-what-a-card-is-worth"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#why-the-exact-printing-decides-what-a-card-is-worth">Why the exact printing decides what a card is worth</a></h2>
<p>The same card is often printed more than once: a regular print, a foil, an alternate art, a higher-rarity parallel. Some share one illustration, some even share one collector number, and their prices can differ by multiples. A scanner that only recognizes the picture can name the card and then give you the price of the wrong print.</p>
<p>I measured this on a test set where half the cards shared a collector number with another print. Matching the artwork picked the right print 89.5% of the time, even when it was given the correct number. One miss in ten is fine for a search box and bad for a collection total, so Foilcase never files a look-alike silently. It asks.</p>
<h2 id="how-the-scanner-identifies-a-card"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#how-the-scanner-identifies-a-card">How the scanner identifies a card</a></h2>
<p>You hold the card inside the guide on the Scan screen. Foilcase reads the artwork and the printed collector number together, on the phone, with Apple&#39;s Vision framework, and the card is identified, priced and filed in about a second.</p>
<ul>
<li><strong>It adds a card on its own only when the artwork and the number agree.</strong> For a look-alike print, an alternate art or a number it couldn&#39;t read, it shows the candidates instead of guessing.</li>
<li><strong>Look-alikes are asked about once.</strong> Parallels that share an illustration are asked about and then remembered, so a playset of four is one question and four cards.</li>
<li><strong>Foil or not is never guessed.</strong> No photo reliably shows the finish, and the price depends on it, so you pick.</li>
<li><strong>Photos work too.</strong> Images picked from your library, several at a time, go through the same recognition.</li>
</ul>
<p>When the scanner misses, search by name or collector number instead. For the best hit rate, lay the card flat, fill the guide, keep the collector number in frame, and tilt foils away from the light.</p>
<h2 id="where-the-prices-come-from-and-how-fresh-they-are"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#where-the-prices-come-from-and-how-fresh-they-are">Where the prices come from and how fresh they are</a></h2>
<p>Prices are collected once a day from public marketplace data, as US market prices in US dollars. Every figure carries its source and the date it was taken, and each card is valued at the price for its own finish.</p>
<p>Once prices are more than seven days old, the portfolio says so in its headline, with a line like &quot;These prices are 2 weeks old&quot;, instead of showing an old total as if it were current.</p>
<p>Treat the numbers as estimates: a thin market can swing hard on a single sale. Price history starts on the day recording started, because nobody sells the past for these games, so the chart fills in as the days accumulate.</p>
<h2 id="raw-graded-and-sealed-are-kept-apart"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#raw-graded-and-sealed-are-kept-apart">Raw, graded and sealed are kept apart</a></h2>
<p>Raw cards, graded cards and sealed product are separate items, each with its own purchase price and condition. A graded copy and a loose one are different assets, and adding them together makes the total meaningless. Graded cards have no automatic price source, and Foilcase won&#39;t derive one from the raw price: you enter a value, or the card stays unpriced.</p>
<figure><img src="https://treamz.me/blog/trading-card-scanner-app/portfolio.jpg" alt="Foilcase Portfolio screen showing what was paid, what the cards are worth now, the unrealized gain and the return, with the price source and date at the bottom" width="644" height="1400" loading="lazy" decoding="async"><figcaption>The portfolio: paid, worth now, gain and return, with the price date underneath</figcaption></figure>
<p>The portfolio shows paid, worth now, unrealized gain and return, per card and overall. Gain is computed only over cards that have both a purchase price and a market price, and the screen says how many cards that covers.</p>
<h2 id="no-account-and-the-collection-stays-on-your-iphone"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#no-account-and-the-collection-stays-on-your-iphone">No account, and the collection stays on your iPhone</a></h2>
<p>There is nothing to sign up for, no ads and no analytics. The App Store privacy label reads &quot;Data Not Collected&quot;. Camera frames are processed on the phone and never saved or sent.</p>
<p>The collection lives on the device as an append-only ledger: every change is a new entry and nothing is overwritten, so a mis-tap is never a lost card. The network is used for card catalogs and images, a daily price file that is the same for every user, and your subscription status.</p>
<p>The cost: there is no cloud sync yet. The collection is included in an encrypted iPhone or iCloud device backup, but a phone lost without a backup takes it along. Sync and export are being worked on.</p>
<h2 id="who-its-for-and-who-it-isnt"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#who-its-for-and-who-it-isnt">Who it&#39;s for, and who it isn&#39;t</a></h2>
<p>It suits you if you care which printing you own, scan in volume, and want what you paid next to what a card is worth. It doesn&#39;t suit you if:</p>
<ul>
<li><strong>You want a fully free app.</strong> After the trial, Pro is $7.99 a month or $49.99 a year in the US, and it unlocks the whole app.</li>
<li><strong>Your game isn&#39;t one of the five it supports.</strong> The list is on the app&#39;s first screen.</li>
<li><strong>You want condition graded from a photo, or live sold-listing comps.</strong> Neither is built, on purpose.</li>
<li><strong>You need sync between devices or an export today.</strong> Neither exists yet.</li>
</ul>
<h2 id="how-to-get-started"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#how-to-get-started">How to get started</a></h2>
<ol>
<li>Install <a href="https://apps.apple.com/us/app/foilcase-tcg-card-scanner/id6813665558">Foilcase from the App Store</a>. It needs iOS 17 or later.</li>
<li>Start the 14-day free trial, monthly or annual. To avoid a charge, cancel at least 24 hours before it ends in Settings › Apple Account › Subscriptions.</li>
<li>Pick the card games you collect. Each catalog is a separate download, so this step needs a connection.</li>
<li>Open Scan and hold a card in the guide with its collector number visible, or pick photos, or search in Browse.</li>
<li>Answer when it asks which print, or whether a card is foil.</li>
<li>Add what you paid and the condition. For a graded card, enter its value.</li>
<li>Open Portfolio for the total, the gain or loss, and the date the prices describe.</li>
</ol>
<p>A wrong match, a question or a game you want supported goes to the <a href="https://treamz.me/foilcase/support">Foilcase support page</a>. I answer that email myself.</p>
<p>How it&#39;s built, for developers: <a href="https://treamz.me/blog/identifying-card-printings-on-device">Identifying card printings on device with Vision and OCR</a>.</p>
<p>How it compares with Collectr, TCGplayer, Ludex, PriceCharting, HoloDex and others: <a href="https://treamz.me/blog/tcg-card-scanner-apps-compared">TCG card scanner apps that price your collection: 8 compared</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#faq">FAQ</a></h2>
<h3 id="what-app-can-i-use-to-scan-trading-cards-and-see-what-theyre-worth"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#what-app-can-i-use-to-scan-trading-cards-and-see-what-theyre-worth">What app can I use to scan trading cards and see what they&#39;re worth?</a></h3>
<p>Look for a scanner that identifies the exact printing, not just the card name, and shows where its prices come from. Foilcase, an iPhone app, reads the artwork and the collector number together and prices each card daily from public marketplace data, with the source and date beside every figure. It is free to download, with a 14-day free trial.</p>
<h3 id="how-do-i-know-which-printing-of-a-card-i-have"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#how-do-i-know-which-printing-of-a-card-i-have">How do I know which printing of a card I have?</a></h3>
<p>Check the collector number and set code printed on the card, then the rarity mark and whether it is foil, because two prints can share an illustration and even a number. Foilcase reads the artwork and number together and asks you when more than one print could match.</p>
<h3 id="is-there-a-card-scanner-app-without-scan-limits"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#is-there-a-card-scanner-app-without-scan-limits">Is there a card scanner app without scan limits?</a></h3>
<p>Foilcase doesn&#39;t meter scanning: no per-card counter, no credits, no scan packs. It is free to download, and scanning is part of the Pro subscription, $7.99 a month or $49.99 a year in the US, with a 14-day free trial on both plans and no ads.</p>
<h3 id="where-do-foilcases-card-prices-come-from"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#where-do-foilcases-card-prices-come-from">Where do Foilcase&#39;s card prices come from?</a></h3>
<p>Prices are collected once a day from public marketplace data, as US market prices in US dollars, and each shows its source and date. They are estimates, and the app says so when they are more than a week old.</p>
<h3 id="can-foilcase-track-graded-cards-and-sealed-products"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#can-foilcase-track-graded-cards-and-sealed-products">Can Foilcase track graded cards and sealed products?</a></h3>
<p>Yes. Raw, graded and sealed are separate items with their own purchase price and condition. Graded cards have no automatic price source, so you enter their value or they stay unpriced.</p>
<h3 id="does-foilcase-need-an-account-and-where-is-my-collection-stored"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#does-foilcase-need-an-account-and-where-is-my-collection-stored">Does Foilcase need an account, and where is my collection stored?</a></h3>
<p>No account is needed. The collection is stored on your iPhone in an append-only ledger and included in encrypted device backups. There is no cloud sync yet.</p>
<h3 id="which-card-games-does-foilcase-support"><a class="anchor" href="https://treamz.me/blog/trading-card-scanner-app#which-card-games-does-foilcase-support">Which card games does Foilcase support?</a></h3>
<p>Foilcase supports five trading card games, each downloaded as its own catalog. The list is on the app&#39;s first screen, and games can be added or removed at any time.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Sourdough schedule planner that works back from bread time</title>
      <link>https://treamz.me/blog/sourdough-schedule-planner-app</link>
      <guid isPermaLink="true">https://treamz.me/blog/sourdough-schedule-planner-app</guid>
      <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
      <description>Proofday is an iPhone sourdough planner. Tell it when you want bread and it schedules every feed, fold and bake around your kitchen temperature and sleep.</description>
      <category>iOS</category>
      <category>Sourdough</category>
      <category>Baking</category>
      <category>Bake Planner</category>
      <category>Live Activities</category>
      <content:encoded><![CDATA[<p>Proofday is a sourdough planner for iPhone. Pick a recipe, say when you want
bread, and it works backwards: when to feed the starter, mix, fold, shape, chill
and bake, adjusted for your kitchen temperature and kept out of the hours you
sleep. It&#39;s for home bakers fitting bread around a job, it needs iOS 17 or
later, and it&#39;s
<a href="https://apps.apple.com/us/app/proofday-sourdough-planner/id6826169140">free to download on the App Store</a>,
with a subscription after the setup.</p>
<p>I build it on my own. Here&#39;s how it schedules a bake and where it stops
helping.</p>
<h2 id="why-is-sourdough-so-hard-to-time"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#why-is-sourdough-so-hard-to-time">Why is sourdough so hard to time?</a></h2>
<p>Three things set the clock. The starter has to be near its peak when it goes
into the dough, and that depends on the feed and the warmth of the jar. Bulk
fermentation runs on temperature: a few degrees cooler and it needs hours more.
And a bake spans a day and a half, so the short hands-on steps land wherever
the dough decides, often at work or after midnight.</p>
<h2 id="how-do-i-plan-a-sourdough-bake-backwards-from-when-i-want-bread"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#how-do-i-plan-a-sourdough-bake-backwards-from-when-i-want-bread">How do I plan a sourdough bake backwards from when I want bread?</a></h2>
<p>Start from the ready time and count back. Proofday&#39;s planner keeps each step
near its ideal length and every hands-on step outside your quiet hours (11 PM
to 7 AM by default). To bridge the night it stretches what can wait: mostly the
cold proof (8 to 24 hours), sometimes a slower 1:5:5 or 1:10:10 levain feed.</p>
<p>The Beginner Country Loaf, ready at 10:00 AM on Saturday in a 23 °C (73 °F)
kitchen, for a new starter:</p>
<ul>
<li><strong>Friday 7:10 AM:</strong> build the levain, 1:2:2.</li>
<li><strong>2:55 PM:</strong> mix, then four sets of folds from 4:40 to 6:10 PM.</li>
<li><strong>9:40 PM:</strong> preshape, then shape and into the fridge by 10:30.</li>
<li><strong>Saturday 7:00 AM:</strong> preheat; bake at 7:45, lid on, then lid off.</li>
<li><strong>8:30 AM:</strong> cool, cut to 90 minutes to make 10:00. Two hours is ideal.</li>
</ul>
<p>Ask for 9:00 and it explains why that can&#39;t work (preheating can&#39;t start
before 7:00) and offers 9:30. Evening bread works too: 7 PM on Saturday means
shaping at 10:45 PM on Friday and baking at 4:15 PM.</p>
<figure><img src="https://treamz.me/blog/sourdough-schedule-planner-app/plan.jpg" alt="Proofday&#39;s plan for a loaf ready Saturday at 10 AM" width="644" height="1400" loading="lazy" decoding="async"><figcaption>Bread at 10 on Saturday</figcaption></figure>
<p>Quiet hours are the only window it protects, not your working hours, so read
the preview before you start. <strong>Start now</strong> plans forwards instead.</p>
<h2 id="how-does-kitchen-temperature-change-a-sourdough-schedule"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#how-does-kitchen-temperature-change-a-sourdough-schedule">How does kitchen temperature change a sourdough schedule?</a></h2>
<p>Proofday&#39;s model assumes every 7 °C (about 13 °F) cooler roughly doubles
fermentation time. From a 5-hour bulk at 24 °C (75 °F) with 20% levain,
that gives:</p>
<div class="table"><table>
<thead>
<tr>
<th>Kitchen</th>
<th>Bulk fermentation</th>
<th>Starter peak, 1:2:2 feed</th>
</tr>
</thead>
<tbody><tr>
<td>18 °C (64 °F)</td>
<td>about 9 h</td>
<td>about 12.5 h</td>
</tr>
<tr>
<td>20 °C (68 °F)</td>
<td>about 7.5 h</td>
<td>about 10.5 h</td>
</tr>
<tr>
<td>22 °C (72 °F)</td>
<td>about 6 h</td>
<td>about 8.5 h</td>
</tr>
<tr>
<td>24 °C (75 °F)</td>
<td>about 5 h</td>
<td>about 7 h</td>
</tr>
<tr>
<td>27 °C (81 °F)</td>
<td>about 3.5 h</td>
<td>about 5 h</td>
</tr>
</tbody></table></div>
<p>These are starting estimates, trusted between 16 and 30 °C (61 to 86 °F), and
your logged bakes and starter peaks tune them.</p>
<p>The real signal is the dough. At mixing, a spoonful goes into a small jar, the
aliquot, and you photograph its rise. The shaping target depends on dough
temperature, 50% at 24 °C and 80% at 20 °C; when it&#39;s reached, <strong>Aliquot hit
target</strong> re-plans the rest.</p>
<figure><img src="https://treamz.me/blog/sourdough-schedule-planner-app/starter.jpg" alt="The starter screen with a predicted peak window and the measured rise" width="644" height="1400" loading="lazy" decoding="async"><figcaption>When the starter will peak</figcaption></figure>
<h2 id="what-reminds-me-during-the-bake"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#what-reminds-me-during-the-bake">What reminds me during the bake?</a></h2>
<ul>
<li><strong>A notification for every step</strong>, with <strong>Done</strong>, <strong>+15 min</strong> and <strong>Snooze 5
min</strong> on the Lock Screen.</li>
<li><strong>A Live Activity</strong> with a countdown and Done and +15 min buttons. Because of
iOS limits, it appears once you open Proofday within seven hours of the next
step.</li>
<li><strong>A fold timer</strong> that keeps the screen awake and buzzes when a set is due.</li>
<li><strong>Two widgets</strong>: Next bake step and Starter peak.</li>
<li><strong>Need more time</strong> shifts the rest of the plan; the cold proof absorbs what
it can, so the ready time often holds.</li>
</ul>
<h2 id="what-does-proofday-do-with-my-data"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#what-does-proofday-do-with-my-data">What does Proofday do with my data?</a></h2>
<p>Starters, photos, plans, the journal and settings stay on your iPhone. There&#39;s
no account, no ads, no usage analytics, no tracking and no sync; the data rides
in your iCloud or computer backup and goes when you delete the app.</p>
<p>Two things leave the phone. A Crumb Check, an analysis of your crumb that you
ask for after a bake, sends the cut-loaf photo and that bake&#39;s numbers to my
server, which passes them to an AI model from Anthropic and stores none of it,
keeping only an App Attest public key and daily counts for the rate limit.
Under its terms, Anthropic doesn&#39;t train on API data and keeps requests only
for a limited time. RevenueCat, which runs the subscriptions, gets the App
Store&#39;s purchase record with a random ID. The App Store label lists photos,
other user content and purchase history, all Data Not Linked to You. The
<a href="https://treamz.me/proofday/privacy">privacy policy</a> has the rest.</p>
<h2 id="who-is-proofday-for-and-who-isnt-it-for"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#who-is-proofday-for-and-who-isnt-it-for">Who is Proofday for, and who isn&#39;t it for?</a></h2>
<p>It&#39;s for bakers who want bread at a set time without the arithmetic, or want
to know when a starter will peak. A 14-day program covers
making a starter from scratch.</p>
<p>It isn&#39;t for you if:</p>
<ul>
<li><strong>You want it free.</strong> The download and the setup quiz are free, and the quiz
plans your first bake. After that it needs Proofday Pro: $39.99 a year with a
7-day free trial, or $6.99 a week (US prices).</li>
<li><strong>You&#39;re not on iPhone.</strong> It&#39;s built for iPhone, on iOS 17 or later, in
English. No Android.</li>
<li><strong>You bake your own formula.</strong> Version 1.0 has no recipe editor; you plan
from eight built-in recipes, from a country loaf to pizza and a gluten-free
loaf.</li>
<li><strong>You trust the clock over the jar.</strong> It&#39;s a model. The dough has the final
word.</li>
</ul>
<h2 id="how-to-get-started"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#how-to-get-started">How to get started</a></h2>
<ol>
<li>Install
<a href="https://apps.apple.com/us/app/proofday-sourdough-planner/id6826169140">Proofday from the App Store</a>.</li>
<li>Answer the setup quiz, including your kitchen temperature and quiet hours.
It ends with a plan for your first bake.</li>
<li>Allow notifications, then start the free trial or subscribe.</li>
<li>Tap <strong>Plan a bake</strong>, choose a recipe, set the temperature and <strong>Bread ready
at…</strong>, and switch on <strong>My starter is already ripe</strong> if it is.</li>
<li>Check the preview, tap <strong>Start this plan</strong>, and add the Next bake step
widget.</li>
</ol>
<p>Questions go to the <a href="https://treamz.me/proofday/support">support page</a>.</p>
<p>How it&#39;s built, for developers: <a href="https://treamz.me/blog/planning-sourdough-backwards-in-swift">Planning sourdough backwards in Swift, with quiet hours</a>.</p>
<p>How it compares with Sourdough Planner, BreadMe, BackPlaner, Dough Timer and others: <a href="https://treamz.me/blog/sourdough-planner-apps-compared">Sourdough planner apps that work back from bake time: 7 compared</a>.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#faq">FAQ</a></h2>
<h3 id="how-do-i-plan-a-sourdough-bake-around-a-work-schedule"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#how-do-i-plan-a-sourdough-bake-around-a-work-schedule">How do I plan a sourdough bake around a work schedule?</a></h3>
<p>Count back from when you want to eat: cooling, baking, cold proof, shaping,
bulk, then the starter feed. The cold proof is the flexible part, so you can
shape in the evening and bake the next day. Proofday does this count on
iPhone, but it keeps steps out of your sleep, not your working hours.</p>
<h3 id="when-should-i-feed-my-starter-to-bake-at-a-certain-time"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#when-should-i-feed-my-starter-to-bake-at-a-certain-time">When should I feed my starter to bake at a certain time?</a></h3>
<p>So that it peaks when you mix. In Proofday&#39;s model, at 24 °C (75 °F) a starter
peaks about 5 hours after a 1:1:1 feed, 7 after 1:2:2 and 11 after 1:5:5, and
takes about half again as long at 20 °C (68 °F). It tunes to your starter after
three logged peaks.</p>
<h3 id="how-long-does-bulk-fermentation-take-at-my-kitchen-temperature"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#how-long-does-bulk-fermentation-take-at-my-kitchen-temperature">How long does bulk fermentation take at my kitchen temperature?</a></h3>
<p>Proofday&#39;s model starts from 5 hours at 24 °C (75 °F) with 20% levain and
roughly doubles that for every 7 °C (13 °F) cooler: about 6 hours at 22 °C, 7.5
at 20 °C and 9 at 18 °C. These are estimates; bulk is done when an aliquot jar
reaches its target rise.</p>
<h3 id="whats-the-earliest-i-can-have-sourdough-bread-in-the-morning"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#whats-the-earliest-i-can-have-sourdough-bread-in-the-morning">What&#39;s the earliest I can have sourdough bread in the morning?</a></h3>
<p>With Proofday&#39;s default quiet hours, 11 PM to 7 AM, about 9:30 AM for a
Dutch-oven loaf: 45 minutes of preheating from 7:00, 45 of baking and at least
an hour of cooling. Ending quiet hours earlier allows earlier bread.</p>
<h3 id="is-there-a-sourdough-timer-app-that-reminds-me-when-to-fold"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#is-there-a-sourdough-timer-app-that-reminds-me-when-to-fold">Is there a sourdough timer app that reminds me when to fold?</a></h3>
<p>Yes. Proofday, for iPhone, sends a reminder for every fold and step with Done,
+15 min and Snooze, counts down in a Live Activity, and has a fold timer that
buzzes when a set is due.</p>
<h3 id="is-proofday-free"><a class="anchor" href="https://treamz.me/blog/sourdough-schedule-planner-app#is-proofday-free">Is Proofday free?</a></h3>
<p>Proofday is free to download, and the setup quiz plans your first bake. After
that it takes Proofday Pro: $39.99 a year after a 7-day free trial, or $6.99 a
week with no trial (US prices).</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to play animated SVG in Flutter (flutter_svg won&apos;t)</title>
      <link>https://treamz.me/blog/flutter-svg-animation</link>
      <guid isPermaLink="true">https://treamz.me/blog/flutter-svg-animation</guid>
      <pubDate>Sat, 15 Aug 2026 00:00:00 GMT</pubDate>
      <description>flutter_svg shows animated SVGs as still pictures. svg_animate plays their SMIL and CSS @keyframes in Flutter, in pure Dart. How it works, and the alternatives.</description>
      <category>Flutter</category>
      <category>Dart</category>
      <category>SVG</category>
      <category>Animation</category>
      <category>Lottie</category>
      <category>Performance</category>
      <content:encoded><![CDATA[<p><strong>Short answer:</strong> flutter_svg doesn&#39;t play SVG animations: it skips SMIL
elements like <code>&lt;animate&gt;</code> and ignores <code>&lt;style&gt;</code>, so the file shows as a still
picture. To play the animation that&#39;s already in the SVG, add
<a href="https://pub.dev/packages/svg_animate">svg_animate</a> and use
<code>AnimatedSvgPicture</code> where you&#39;d use <code>SvgPicture</code>. It takes the same arguments:</p>
<div class="code" data-lang="bash"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">flutter</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4"> pub</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4"> add</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4"> svg_animate</span></span></code></pre></div>
<div class="code" data-lang="dart"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">AnimatedSvgPicture.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">asset</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'assets/spinner.svg'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, width</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 48</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span></code></pre></div>
<p>It plays SMIL, CSS <code>@keyframes</code> and CSS motion paths, in pure Dart. The rest of
this post is how it works, the three bugs real files exposed, and how it
compares with Lottie, Rive and the other SVG packages.</p>
<p>Our designer sent me a folder of animated SVGs. A spinner, a progress bar, a
few icons. Nice stuff, all of it animated right there in the file with plain
CSS and SMIL.</p>
<p>I dropped them into flutter_svg and got static pictures.</p>
<p>I spent an evening convinced I&#39;d broken something. Wrong widget, wrong asset
path, something dumb. Turns out no: flutter_svg just skips <code>&lt;animate&gt;</code>. And it
ignores <code>&lt;style&gt;</code> too, which is worse, because that means a stylesheet-driven
SVG doesn&#39;t even render with the right colors, let alone move.</p>
<p>The usual answer is &quot;convert it to Lottie&quot;. I didn&#39;t want to go back to the
designer and ask him to rebuild everything in After Effects when the animation
was already sitting in the file he&#39;d sent me. So I wrote svg_animate.</p>
<h2 id="how-svganimate-works-a-flipbook-of-static-svgs"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#how-svganimate-works-a-flipbook-of-static-svgs">How svg_animate works: a flipbook of static SVGs</a></h2>
<p>flutter_svg draws SVGs by compiling them into a binary format with
<code>vector_graphics_compiler</code>, then replaying that. That compiler is good. I had no
interest in writing a renderer.</p>
<p>So: don&#39;t. Take the animated document, work out what every animated attribute
is at time <em>t</em>, write those values back into the markup, and hand the result to
the existing compiler. Do that once per frame ahead of time, then flip through
the compiled frames like a flipbook.</p>
<p>A frame costs exactly what a static SVG costs to draw, because it <em>is</em> a static
SVG. You pay at load instead.</p>
<div class="code" data-lang="dart"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">AnimatedSvgPicture.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">asset</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'assets/spinner.svg'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, width</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 48</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span></code></pre></div>
<p>Same arguments as <code>SvgPicture</code>. Reuses flutter_svg&#39;s <code>SvgTheme</code> and
<code>ColorMapper</code> so nothing in an existing app breaks. If the file has no animation
in it, it renders like a normal <code>SvgPicture</code> and doesn&#39;t even start a ticker.</p>
<p>Two evenings later the spinner was spinning. I felt great about myself for
roughly a week.</p>
<h2 id="bug-1-an-svgator-banner-that-rendered-blank"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#bug-1-an-svgator-banner-that-rendered-blank">Bug 1: an SVGator banner that rendered blank</a></h2>
<p>A 450×450 promo banner exported from SVGator. Coins, sparkles, a train, a
&quot;BUY BONUS&quot; badge. 90 KB of markup with five WebP images base64&#39;d into it.</p>
<p>It rendered as nothing at all. Not &quot;wrong&quot; — a blank box.</p>
<p>Here&#39;s where I want to be honest about how I debugged it, because I got it wrong
first. I built a minimal test case: a tiny SVG with one <code>&lt;image&gt;</code> in it,
referenced through <code>&lt;use&gt;</code>. Nothing drew. I tried the same file through plain
flutter_svg. Nothing drew there either. Great, I thought, images just don&#39;t work
on this pipeline, not my problem.</p>
<p>That conclusion was garbage. I&#39;d hand-rolled the test PNG as a base64 string and
it was truncated — zlib couldn&#39;t even decompress it. I&#39;d proven nothing except
that a broken PNG doesn&#39;t render.</p>
<p>Generated a real one. Ran it again. Images render fine. <code>&lt;use&gt;</code> pointing at an
image renders too, just at the wrong size.</p>
<p>So back to the real file, this time properly: feed the raw SVG straight into
<code>vector_graphics_compiler</code>, no code of mine involved.</p>
<div class="code"><pre class="shiki"><code>Failed assertion: &#39;width &gt; 0 &amp;&amp; height &gt; 0&#39;  (writeDrawImage)</code></pre></div>
<p>It dies in the compiler. Before any animation logic. <code>SvgPicture</code> would have
shown the same blank box.</p>
<p>Bisecting the file by deleting things: strip the 19 <code>&lt;use href=&quot;#img&quot;&gt;</code>
references and it compiles. The compiler loses an image&#39;s declared width and
height when the image is reached through a reference, gets zero, and then
refuses to draw something of zero size — killing the whole picture rather than
that one element.</p>
<p>Fix on my side: expand a <code>&lt;use&gt;</code> that points at an <code>&lt;image&gt;</code> into the image
itself before compiling. Only images — a <code>&lt;use&gt;</code> on a shape is fine, and
copying those would just bloat the document.</p>
<p>The banner rendered. Coins and all.</p>
<h2 id="bug-2-slow-frames-and-27-5-mb-of-memory"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#bug-2-slow-frames-and-27-5-mb-of-memory">Bug 2: slow frames and 27.5 MB of memory</a></h2>
<p>Every compiled frame carries the images inside it, because each frame is a
self-contained picture. 82% of each 96 KB frame was image payload. At the
default 60 fps that&#39;s 300 frames and <strong>27.5 MB</strong>.</p>
<p>Worse than the memory: changing frame cost <strong>6.8 ms</strong>. The frame budget at 60fps
is 16.7 ms. That&#39;s 40% of it, just to decode, on an M-series Mac. On a mid-range
phone it would have blown straight through.</p>
<p>Two fixes, and I&#39;m not proud of the first one.</p>
<p><strong>The image cache.</strong> I went reading how vector_graphics decodes images and found
this:</p>
<div class="code" data-lang="dart"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">final</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> listener </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> FlutterVectorGraphicsListener(id</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> loader.hashCode, ...);</span></span>
<span class="line"><span style="--shiki-light:#6B6B6B;--shiki-light-font-style:italic;--shiki-dark:#8E8E8E;--shiki-dark-font-style:italic">// ...</span></span>
<span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">int </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">_createImageKey</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(int imageId, int format) </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> Object.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">hash</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(_id, imageId, format);</span></span></code></pre></div>
<p>It keys Flutter&#39;s global image cache by the hash code of the loader you hand it.
And mine was:</p>
<div class="code" data-lang="dart"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">int </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">get</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> hashCode </span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">=></span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> Object.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">hash</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">identityHashCode</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(frames), frameIndex);</span></span></code></pre></div>
<p><code>frameIndex</code> in there. Every single frame opened its own namespace in the cache
and re-decoded the same five bitmaps. I had disabled a cache that was sitting
right there, with my own line of code, and then measured the result and blamed
the architecture.</p>
<p>Dropped <code>frameIndex</code>. Frames are still unequal to each other — that&#39;s what <code>==</code>
is for — and two unequal objects sharing a hash code is perfectly legal Dart.
Here it&#39;s the whole point. 6.8 ms → 1.4 ms.</p>
<p><strong>The duplicated bytes.</strong> For memory I nearly went and wrote a parser for the
binary format to find and hoist the image section. Then I realised I didn&#39;t need
to understand the format at all: consecutive frames of one animation are the
same document with a few numbers changed, and the encoder puts everything the
picture embeds at the front. So just compare the frames byte by byte and find
the longest run they all start with.</p>
<p>On the banner that run is 78,772 bytes. The compiler reports 78,538 bytes of
images. It found them, without knowing what it was looking at.</p>
<p>Store that once, keep only the tails, glue a frame back together when it&#39;s
asked for. 27.5 MB → 5.1 MB, and reassembling costs about 0.1 ms.</p>
<p>I like this one because it doesn&#39;t depend on the encoder&#39;s layout. If the format
ever stops front-loading resources, the shared run just gets shorter and nothing
breaks.</p>
<h2 id="bug-3-a-css-spinner-with-no-0-keyframe"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#bug-3-a-css-spinner-with-no-0-keyframe">Bug 3: a CSS spinner with no 0% keyframe</a></h2>
<p>Last one, and it&#39;s the most embarrassing, because it&#39;s the most common CSS
animation on earth:</p>
<div class="code" data-lang="css"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">@keyframes</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6"> spin { to { </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">transform</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">: </span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">rotate</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">360</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">deg</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">) } }</span></span></code></pre></div>
<p>No <code>0%</code>. My code filled the missing end from the element&#39;s own <code>transform</code>
attribute, and when the element didn&#39;t have one, it fell back to... the other
keyframe. So both ends said 360 and the thing sat perfectly still while
everything around it animated.</p>
<p>The missing end has to come from the value the property has <em>outside</em> the
animation — no rotation for a transform, fully opaque for an opacity. With one
wrinkle: you can&#39;t substitute an empty transform, because two transform lists
only interpolate if they&#39;re built the same way. The stand-in has to keep the
shape: <code>translate(0 0)</code> against a translate, <code>scale(1)</code> against a scale.</p>
<h2 id="what-svganimate-doesnt-support"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#what-svganimate-doesnt-support">What svg_animate doesn&#39;t support</a></h2>
<p>Filters. At all. <code>vector_graphics</code> drops <code>&lt;filter&gt;</code> entirely, so drop shadows
and glows just don&#39;t happen — the shapes draw, the effect doesn&#39;t. Same for
<code>mix-blend-mode: plus-lighter</code>, which is exactly what editors reach for to make
something glow.</p>
<p>Also no <code>d</code> morphing, no <code>&lt;script&gt;</code>, no <code>:hover</code>, no event-based <code>begin</code>. If you
need any of that, <a href="https://pub.dev/packages/full_svg_flutter">full_svg_flutter</a>
covers a lot more of the format. It&#39;s a good package. It also bundles a
JavaScript runtime, which is a fair price for what it does and not one I wanted
to pay for a spinner.</p>
<p>Mine adds two pure-Dart packages, both already in flutter_svg&#39;s dependency tree.</p>
<h2 id="svganimate-vs-lottie-rive-and-other-flutter-svg-packages"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#svganimate-vs-lottie-rive-and-other-flutter-svg-packages">svg_animate vs Lottie, Rive and other Flutter SVG packages</a></h2>
<p>&quot;Animated SVG&quot; means different things in different packages. Only some of them
play the animation that&#39;s already written into the file:</p>
<div class="table"><table>
<thead>
<tr>
<th>Package</th>
<th>What it animates</th>
<th>Plays the animation already in the SVG</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>svg_animate</td>
<td>SMIL, CSS <code>@keyframes</code>, CSS motion paths</td>
<td>Yes</td>
<td>Pure Dart, on flutter_svg&#39;s own compiler; no filters</td>
</tr>
<tr>
<td>flutter_svg</td>
<td>Nothing: draws a still picture</td>
<td>No</td>
<td>Skips <code>&lt;animate&gt;</code>, ignores <code>&lt;style&gt;</code></td>
</tr>
<tr>
<td>lottie</td>
<td>Lottie JSON files</td>
<td>No</td>
<td>The animation has to be rebuilt in After Effects and exported as Lottie</td>
</tr>
<tr>
<td>rive</td>
<td><code>.riv</code> files from the Rive editor</td>
<td>No</td>
<td>The animation has to be rebuilt in Rive</td>
</tr>
<tr>
<td>anim_svg</td>
<td>SMIL and CSS <code>@keyframes</code>, converted to Lottie at runtime</td>
<td>Yes, except SVGator&#39;s script-driven exports</td>
<td>Renders with the native thorvg engine through FFI</td>
</tr>
<tr>
<td>full_svg_flutter</td>
<td>Much more of SVG</td>
<td>Yes</td>
<td>Bundles a JavaScript runtime</td>
</tr>
<tr>
<td>animated_svg</td>
<td>A transition between two SVG images, such as play to pause</td>
<td>No</td>
<td>For icon toggles, not animated files</td>
</tr>
<tr>
<td>drawing_animation</td>
<td>A line-drawing effect on <code>&lt;path&gt;</code> strokes</td>
<td>No</td>
<td>Draws paths progressively</td>
</tr>
</tbody></table></div>
<p>If your designer already animated the file, in SVGator, by hand, or anywhere
else that writes SMIL or CSS, use a package from the &quot;Yes&quot; rows. If you&#39;re
starting from scratch and want a timeline editor, Lottie or Rive is the better
tool.</p>
<h2 id="install-svganimate-from-pub-dev"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#install-svganimate-from-pub-dev">Install svg_animate from pub.dev</a></h2>
<p><a href="https://pub.dev/packages/svg_animate">pub.dev/packages/svg_animate</a></p>
<p>SMIL, CSS <code>@keyframes</code>, CSS motion paths (which is how SVGator writes every
movement, so its exports work), play/pause/seek through a controller. It&#39;s one
of the <a href="https://treamz.me/projects/#svg-animate">projects I maintain</a>.</p>
<p>If you&#39;ve got an SVG that renders wrong, send it. Every single thing in this
post came from someone&#39;s file not working.</p>
<h2 id="faq"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#faq">FAQ</a></h2>
<h3 id="does-fluttersvg-support-animated-svgs"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#does-fluttersvg-support-animated-svgs">Does flutter_svg support animated SVGs?</a></h3>
<p>No. flutter_svg draws an SVG as a still picture: SMIL elements such as
<code>&lt;animate&gt;</code> are skipped, and <code>&lt;style&gt;</code> blocks are ignored entirely, so CSS
<code>@keyframes</code> don&#39;t play and colours set in a stylesheet don&#39;t apply either.</p>
<h3 id="how-do-i-play-an-animated-svg-in-flutter"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#how-do-i-play-an-animated-svg-in-flutter">How do I play an animated SVG in Flutter?</a></h3>
<p>Add svg_animate with <code>flutter pub add svg_animate</code> and use
<code>AnimatedSvgPicture</code> where you would use <code>SvgPicture</code>. It takes the same
arguments:</p>
<div class="code" data-lang="dart"><pre class="shiki shiki-themes treamz-light treamz-dark" style="--shiki-light:#2a2a2a;--shiki-dark:#d6d6d6;--shiki-light-bg:#f4f4f4;--shiki-dark-bg:#1c1c1c" tabindex="0"><code><span class="line"><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">AnimatedSvgPicture.</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2">asset</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">(</span><span style="--shiki-light:#4A4A4A;--shiki-dark:#B4B4B4">'assets/spinner.svg'</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">, width</span><span style="--shiki-light:#161616;--shiki-light-font-weight:bold;--shiki-dark:#F2F2F2;--shiki-dark-font-weight:bold">:</span><span style="--shiki-light:#161616;--shiki-dark:#F2F2F2"> 48</span><span style="--shiki-light:#2A2A2A;--shiki-dark:#D6D6D6">)</span></span></code></pre></div>
<p>It plays the SMIL and CSS keyframe animations in the file, and an
<code>AnimatedSvgController</code> adds play, pause and seek. A file with no animation in
it renders like a normal <code>SvgPicture</code>.</p>
<h3 id="do-i-need-lottie-to-animate-an-svg-in-flutter"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#do-i-need-lottie-to-animate-an-svg-in-flutter">Do I need Lottie to animate an SVG in Flutter?</a></h3>
<p>Not when the animation is already in the SVG. Files written with SMIL or CSS
<code>@keyframes</code>, including exports from SVGator, play as they are with
svg_animate, with no round trip through After Effects.</p>
<h3 id="whats-the-difference-between-svganimate-and-animatedsvg"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#whats-the-difference-between-svganimate-and-animatedsvg">What&#39;s the difference between svg_animate and animated_svg?</a></h3>
<p>They solve different problems. svg_animate plays the animation inside an SVG
file: SMIL elements and CSS <code>@keyframes</code>. animated_svg doesn&#39;t read animation
from the file; it transitions between two separate SVG images, such as a play
icon turning into a pause icon.</p>
<h3 id="does-svganimate-work-with-svgator-exports"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#does-svganimate-work-with-svgator-exports">Does svg_animate work with SVGator exports?</a></h3>
<p>Yes, with SVGator&#39;s CSS export: SVGator writes movement as CSS motion paths,
which svg_animate plays, and the image-heavy banner in this post is an SVGator
export. SVGator&#39;s JavaScript export depends on an embedded script, and
svg_animate doesn&#39;t run <code>&lt;script&gt;</code>.</p>
<h3 id="which-svg-animation-features-does-svganimate-support"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#which-svg-animation-features-does-svganimate-support">Which SVG animation features does svg_animate support?</a></h3>
<p>SMIL (<code>&lt;animate&gt;</code>, <code>&lt;animateTransform&gt;</code>, <code>&lt;animateMotion&gt;</code> and <code>&lt;set&gt;</code>), CSS
<code>@keyframes</code> and CSS motion paths. It interpolates numbers, colours, lengths
and transform lists, and it loads SVGs from assets, the network, files, memory
or a string.</p>
<h3 id="what-doesnt-svganimate-support"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#what-doesnt-svganimate-support">What doesn&#39;t svg_animate support?</a></h3>
<p>SVG filters, so no drop shadows or glows; <code>mix-blend-mode: plus-lighter</code>;
morphing a path&#39;s <code>d</code> attribute; <code>&lt;script&gt;</code>; <code>:hover</code>; and event-based <code>begin</code>.
For those, <a href="https://pub.dev/packages/full_svg_flutter">full_svg_flutter</a> covers
more of the format, at the cost of bundling a JavaScript runtime.</p>
<h3 id="is-svganimate-fast-enough-for-production"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#is-svganimate-fast-enough-for-production">Is svg_animate fast enough for production?</a></h3>
<p>Every frame is compiled ahead of time and costs the same to draw as a static
SVG. On the image-heavy banner in this post, switching frames went from 6.8 ms
to 1.4 ms once decoded images were cached, and memory went from 27.5 MB to
5.1 MB once the bytes every frame shares were stored only once.</p>
<h3 id="which-platforms-does-svganimate-run-on"><a class="anchor" href="https://treamz.me/blog/flutter-svg-animation#which-platforms-does-svganimate-run-on">Which platforms does svg_animate run on?</a></h3>
<p>Android, iOS, macOS, Windows and Linux.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
