<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Rens</title>
    <link>https://rensoliemans.nl/</link>
    <description>Rens</description>
    <atom:link href="https://rensoliemans.nl/en/feed.xml" rel="self" type="application/rss+xml"/>
    
    <item>
      <title>Claude vs Me - managing vs hacking</title>
      <link>https://rensoliemans.nl/en/writing/claude-vs-me/</link>
      <guid>https://rensoliemans.nl/en/writing/claude-vs-me/</guid>
      <pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate>
      <description><![CDATA[&lt;p&gt;I was using Anki to learn to recognise bird sounds using &lt;a href=&quot;https://ankiweb.net/shared/info/331539617&quot;&gt;this fantastic deck&lt;/a&gt;. However, there were a couple of problems with the deck, so I decided to create my own deck. I obtained sounds and images from the Dutch &lt;a href=&quot;https://vogelbescherming.nl/&quot;&gt;Vogelbescherming&lt;/a&gt;, wrote some scripts, and my girlfriend and me spent quite a few evenings practising bird sounds. She told some of her birding friends what we were doing and people got interested. However, there were two issues with this approach:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;I do not have the copyright of the audio and images of the Vogelbescherming, and therefore cannot share them.&lt;/li&gt;&lt;li&gt;Non-techies wanted to do this: they had to install Anki, download this shared deck, import it, and use it. Anki is good, but unfortunately not &lt;em&gt;that&lt;/em&gt; friendly in its UX.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;So, I decided to create a website (&lt;a href=&quot;https://vogelgeluidjes.nl&quot;&gt;https://vogelgeluidjes.nl&lt;/a&gt;) which tackled these issues. My experience with LLMs was limited to copy-pasting code to and fro chatinterfaces, which sucks, so I could use this to experiment with LLMs as well. I decided to build the website &lt;em&gt;twice&lt;/em&gt;, once using Claude Code, and once on my own. I wanted to compare the following things (let&#39;s call them our research questions):&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;How long do both methods take?&lt;/li&gt;&lt;li&gt;How does it feel?&lt;/li&gt;&lt;li&gt;What is the end-result, both in quality of the product, and in terms of quality / maintainability of the codebase.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;Answering the first question fairly is tricky, since if I implemented it on my own first, I would have a very in-depth knowledge on a good structure and difficulties, which would be very beneficial for the Claude implementation. If I implement it with Claude and look at the implementation, creating it for myself would be easier. I ended up with a trade-off: implement via Claude first, and don&#39;t look at the code details, focus on the high-level structure and on the results. This might influence the other questions, but I think it&#39;s fair and I&#39;ll explain this later in the section &lt;a href=&quot;#ignore-the-code&quot;&gt;Ignore the code&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Design goals&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;Simple: easy to deploy and maintain in the long-term&lt;/li&gt;&lt;li&gt;Good UX&lt;/li&gt;&lt;li&gt;Data does not leave your device; no telemetry&lt;/li&gt;&lt;li&gt;No user management, secrets, or personal information&lt;/li&gt;&lt;li&gt;Good a11y; use proper HTML elements, avoid a complicated JS framework&lt;/li&gt;&lt;/ul&gt;
&lt;h2&gt;Claude&lt;/h2&gt;
&lt;p&gt;I wrote down some requirements and a description of the website I had in mind and started a chat with Claude to create a &lt;code&gt;SPEC.md&lt;/code&gt; file (Opus 4.6, Extended thinking, 2026-03-10, &lt;a href=&quot;https://claude.ai/share/fe74df13-986d-43b7-a010-a588ae73b594&quot;&gt;link to chat&lt;/a&gt;). With this spec, I started instructing Claude. This is the first real message I sent&lt;sup&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;USER | 2026-03-10 12:20&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;I saved the spec in docs/SPEC.md. The spec refers to birds.json, you can find that in docs/birds.json. It needs some more information on my part: taxonomic groupings, habitat group tags, starter deck species, commonality. For now however, I want you to assume that these will be covered later. I want you to build Phase 1 only. Set up the project, seed script with some placeholder birds, SM-2 in Python with tests, and the review screen with &amp;lt;details&amp;gt;/&amp;lt;summary&amp;gt;. Don&#39;t stub Phase 2-4 code, just build what&#39;s needed now. Keep the code as simple as possible, take special care to avoid overcomplicating the IndexedDB storage.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;I used the following structure when implementing:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;Ask Claude to implement the next phase in the spec&lt;/li&gt;&lt;li&gt;When done, start a new chat with an agent to ask for comprehensive feedback and to check whether the original design goals (in &lt;code&gt;SPEC.md&lt;/code&gt;) were being upheld.&lt;/li&gt;&lt;li&gt;When done, start a new chat to incorporate this feedback. When I am happy, continue to the next phase, and repeat steps 1-3.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;This went quite well. Claude generated a reasonable project structure, the code looked alright. I refrained from fussing over the code details, and focused on the high-level structure. For the first few messages, I had to remind myself to not try to understand the code it created too well (in order to not taint my brain for &lt;a href=&quot;#me&quot;&gt;Part Two: Me&lt;/a&gt;). Within a few minutes, however, this process became automatic: for various reasons, programming with Claude code teaches you to &lt;em&gt;ignore the code&lt;/em&gt;.&lt;/p&gt;
&lt;h3 id=&quot;ignore-the-code&quot;&gt;Ignore the code&lt;/h3&gt;
&lt;p&gt;The first thing I noticed was that due to a few aspects of Claude code&#39;s design, I was automatically ignoring the code it created. Claude:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;is quite slow. Answering a question and getting (a lot) of code takes 10-60 seconds&lt;sup&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; while Claude is &amp;quot;thinking&amp;quot; about it. This is longer than the &lt;a href=&quot;https://www.nngroup.com/articles/response-times-3-important-limits/&quot;&gt;last reponse time limit Jakob Nielsen describes&lt;/a&gt;, &lt;em&gt;&amp;quot;10 seconds is about the limit for keeping the user&#39;s attention&amp;quot;&lt;/em&gt;. This means that it feels very difficult to stay critical of the output because I found myself having to put in a bit of effort to return after each message.&lt;/li&gt;&lt;li&gt;outputs a shitload of code that looks decent at first glance. Occasionally the (amount of) code is reasonable, but occasionally it is not, and telling it do so something simpler instead never works. There are two solutions to overcomplicated output:&lt;ol&gt;&lt;li&gt;think about the problem, devise a simple solution, tell Claude&lt;/li&gt;&lt;li&gt;exit the chat, start a new one with a fresh context, reframe the prompt, pray&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;When doing the former, it often feels like it&#39;s more productive to start coding without Claude. The latter breaks flow, is quite unpredictable and feels like hyperparameter tuning (and I imagine, alchemy)&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;More often than not, I ended up uncritically accepting the huge amounts of code and continuing on. Part of me was also interested in doing this to find out what happens next (I mean, outsourcing programming is part of the sell of AI, right?)&lt;/p&gt;
&lt;h3 id=&quot;overcomplications&quot;&gt;Overcomplications&lt;/h3&gt;
&lt;p&gt;I know all about overcomplicating software, I started programming with OOP. I figured that Claude would be no different and I prepared myself for this point. My &lt;code&gt;SPEC.md&lt;/code&gt; initial instruction focused on simplicity and the spec also highlighted this in various forms&lt;sup&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;And to credit Claude, it started simple! It did duplicate some core JS functionality in server-side python code that was never called, but other than that, the structure was reasonably simple.&lt;/p&gt;
&lt;p&gt;The problem starts when you start adding features, or worse, modify existing features. This can work decently well with a lot of handholding (focus first on making the change easy, then make the easy change), but by default, when you replace a feature with a simpler feature, the code will not become simpler. Claude will implement the simple feature with the old, complicated code as foundation.&lt;/p&gt;
&lt;p&gt;When modifying features, Claude will also happily deviate from prior instructions, either as stated in &lt;code&gt;SPEC.md/CLAUDE.md&lt;/code&gt; or stated explicitly in the conversation before. An example is that I wanted part of my website to work with HTML&#39;s &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/details&quot;&gt;&amp;lt;details&amp;gt;&lt;/a&gt; rather than hiding/showing &lt;code&gt;divs&lt;/code&gt; with JavaScript. I had this very specific requirement stated in my spec, yet I had to remind Claude twice about it after it had removed it in favour of a family of &lt;code&gt;divs&lt;/code&gt;. Perhaps this problem will go away when models get larger and larger context windows, I do not know.&lt;/p&gt;
&lt;p&gt;It is also difficult to stay disciplined about review in these moments. You describe a small feature change, and Claude updates 2 files, removing 12 and adding 25 lines and you&#39;re happy, until you realise that it got something wrong. You describe that and it then adds about 30 more lines, which continues for a few iterations until you&#39;ve spelled out your requirements to the dot, and now end up with a complicated mess. At this point saying &amp;quot;simplify your changes&amp;quot; does not work for me, so what do you do now? Start a new chat with a modified prompt? Code it yourself? Ask it to create a &lt;code&gt;feature.md&lt;/code&gt; file with the requirements and start a new chat? Or do you accept it, since it works… 😈&lt;/p&gt;
&lt;h3&gt;Speed!&lt;/h3&gt;
&lt;p&gt;The speed with which Claude can output useful code really is amazing, at least on a greenfield project like this. This is especially so with languages that I&#39;m not fluent in (CSS!) and for parts that I do not care too much about. My app is about bird sounds, and I needed bird sounds. &lt;a href=&quot;https://xeno-canto.org&quot;&gt;xeno-canto.org&lt;/a&gt; is a fantastic resource for free bird sounds, and Claude nearly one-shotted a browser extension that enabled me to easily match appropriate bird sounds and bird images.&lt;/p&gt;
&lt;p&gt;In the end, Claude did in 7 hours what I did in 16 hours, and Claude had in that time built some features that I didn&#39;t end up implementing (because they weren&#39;t necessary; something I found out when I actually built and tested it).&lt;/p&gt;
&lt;h3 id=&quot;higher-level-programming&quot;&gt;A higher level of programming?&lt;/h3&gt;
&lt;p&gt;This is a possible answer to the problems described in the previous two sections: LLMs are just yet another, higher level of abstraction. It&#39;s &lt;em&gt;fine&lt;/em&gt; to ignore the code, just like it&#39;s fine to ignore the bytecode that compilers produce. Overcomplications are a problem for humans reading the code and &lt;em&gt;do not matter&lt;/em&gt; as long as Claude can keep fixing bugs and adding features in the future.&lt;/p&gt;
&lt;p&gt;We programmed in assembly before, and now in python we do not care about registers, heaps or stacks, or even pointers! With LLMs we now just care about markdown, bullet points and the English language. However, there are two problems with this argument:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;While LLMs are a higher level of abstraction than say python or JavaScript, the abstraction is leaky in a way that compilers aren&#39;t. LLMs are unpredictable and quite frequently wrong. A compiler &lt;em&gt;can&lt;/em&gt; be wrong, but in that case it is actually &lt;em&gt;wrong&lt;/em&gt;. It violates a contract, or not. You can create a bug report and it should be fixed. This concept of a definite contract does not map to LLMs.&lt;/li&gt;&lt;li&gt;Claude code is a closed-source and &lt;a href=&quot;https://github.com/anomalyco/opencode/issues/7410&quot;&gt;Anthropic actively blocks open-source alternatives such as OpenCode&lt;/a&gt;. I cannot run local AI models on my poor old laptop. I&#39;ll get back to this in &lt;a href=&quot;#free-software&quot;&gt;Free software&lt;/a&gt;.&lt;/li&gt;&lt;/ol&gt;
&lt;h3 id=&quot;free-software&quot;&gt;Free software&lt;/h3&gt;
&lt;p&gt;As someone who values free software partly because it allows me to hack on software on my own terms, this dis-empowers me. According to &lt;a href=&quot;https://www.canirun.ai/&quot;&gt;Can I Run AI locally?&lt;/a&gt;, my laptop can run no AI models (I have no dedicated GPU), and I don&#39;t have the money to purchase a heavy GPU + pay for the electricity bill. Using LLMs &lt;em&gt;can&lt;/em&gt; be freedom-respecting, but it is hard. I don&#39;t like Matlab for this reason, and I don&#39;t like this part of current SOTA LLMs. Granted, this is a temporary issue and not completely unique to LLMs, but it is a currently relevant issue.&lt;/p&gt;
&lt;h3 id=&quot;subtle-bugs&quot;&gt;Subtle bugs&lt;/h3&gt;
&lt;p&gt;I created an Anki alternative, which is spaced repetition software. &lt;a href=&quot;https://en.wikipedia.org/wiki/Spaced_repetition&quot;&gt;Wiki has a good article on it&lt;/a&gt;, and &lt;a href=&quot;https://ncase.me/remember/&quot;&gt;Nicky Case has a great game&lt;/a&gt; about it, so I won&#39;t explain it in detail here. However, the gist is that you learn to memorise flashcards as the software takes care of when you see what flashcards. You always get a mix of cards you have seen before (to help retain them) and new cards (to learn new things), and the order in which you get this matters.&lt;/p&gt;
&lt;p&gt;I did not fully specify this, and Claude just did some arbitrary thing: first the existing cards, and then the new ones. This came up immediately in testing, since it is pretty bad. It is a somewhat easy fix, but highlights a&lt;/p&gt;
&lt;p&gt;The code Claude writes is generally good and generally works. However there are subtle bugs that don&#39;t surface until you test your program thoroughly or actually understand the code (which is very difficult: &lt;a href=&quot;#ignore-the-code&quot;&gt;Ignore the code&lt;/a&gt;).&lt;/p&gt;
&lt;h3&gt;Unpredictable vs nondeterministic&lt;/h3&gt;

&lt;h2 id=&quot;me&quot;&gt;Me&lt;/h2&gt;
&lt;p&gt;With the initial version of Claude done, I did my best to forget all about it and start creating it on my own. I took more than twice as long to create what Claude did, and if I wasn&#39;t familiar with Flask, it would have taken me much longer. My goal was to compare these two versions in terms of &lt;em&gt;output&lt;/em&gt; and &lt;em&gt;process&lt;/em&gt;. Recall my three research questions:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;How long do both methods take?&lt;/li&gt;&lt;li&gt;How does it feel?&lt;/li&gt;&lt;li&gt;What is the end-result, both in quality of the product, and in terms of quality / maintainability of the codebase.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;Answering question 1 is easy: 7 hours for Claude, 16 hours for myself. Question 2 is more interesting, so let&#39;s get into the key difference: programming with Claude feels like being a manager / PO; programming on my own feels like hacking.&lt;/p&gt;
&lt;h3&gt;Hacker vs manager&lt;/h3&gt;
&lt;p&gt;Here, I see a hacker as someone who loves to dive deep into something and completely understand something. I for one would still love to create an interpreter for lisp for example, or my own kernel. I don&#39;t consider myself 100% of a hacker (I haven&#39;t created those things yet), but I have a desire to do so mainly because it forces me to learn stuff that I take for granted.&lt;/p&gt;
&lt;p&gt;A manager on the other hand is someone who just wants to see results. They have a list of requirements and want to see it finished. It doesn&#39;t matter if the code is good or if someone understands it (as long as bad code / lack of understanding doesn&#39;t hinder future fulfilment of requirements). What matters is the creation of concrete value.&lt;/p&gt;
&lt;p&gt;I am a hacker for some parts, and a manager for some. For CSS I&#39;m a manager and I want something that looks good. For other parts, I&#39;m a hacker. I&#39;m building spaced repetition software, and my initial version with Claude Code was created without me knowing how spaced repetition actually works. This makes me feel very uneasy.&lt;/p&gt;
&lt;p&gt;Creating my own version forced me to read articles about the &lt;a href=&quot;https://supermemo.guru/wiki/Optimum_interval&quot;&gt;Optimum interval&lt;/a&gt; or the &lt;a href=&quot;https://supermemo.guru/wiki/Forgetting_index&quot;&gt;Forgetting index&lt;/a&gt;. I have to understand these concepts, know how they relate to one another (and my goals) and implement them.&lt;/p&gt;
&lt;p&gt;Note how in the paragraph above, I called this version &amp;quot;my own version&amp;quot;. Intuitively, it feels like the Claude Code version is not &amp;quot;my&amp;quot; program. It seems like the US copyright people would agree with me on this, but legality aside, it only felt like my program once I started coding it.&lt;/p&gt;
&lt;h3&gt;Ownership&lt;/h3&gt;

&lt;h2&gt;Footnotes&lt;/h2&gt;
&lt;div class=&quot;footnote&quot; id=&quot;fn-1&quot;&gt;&lt;sup&gt;1&lt;/sup&gt; You can view the full Claude code history at &lt;a href=&quot;https://rensoliemans.nl/assets/claude-vs-me/claude-history.txt&quot;&gt;claude-history.txt&lt;/a&gt; &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/div&gt;
&lt;div class=&quot;footnote&quot; id=&quot;fn-2&quot;&gt;&lt;sup&gt;2&lt;/sup&gt; Mind, compared to writing all these characters yourself this is very fast, but compared to other computer operations, it is painfully slow. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/div&gt;
&lt;div class=&quot;footnote&quot; id=&quot;fn-3&quot;&gt;&lt;sup&gt;3&lt;/sup&gt; The full initial &lt;code&gt;SPEC.md&lt;/code&gt; &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/div&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;  # BirdSRS — Implementation Specification

A spaced-repetition webapp for memorizing bird sounds of the Netherlands.

---

## 1. Product Overview

### Core concept
Users listen to a bird sound recording, think about which species it is, reveal the answer, then self-rate their recall (Again / Hard / Good / Easy). The app schedules future reviews using SM-2.

### Key principles
- **Local-first**: SRS state lives in the browser (IndexedDB). The server stores a canonical copy for sync, but the app works without network during reviews.
- **Boring technology**: Python + Flask, SQLite, minimal JS. No build step. No SPA framework. `uv` for Python dependency management.
- **Simple auth**: Generated usernames (like Mullvad VPN), no passwords, no email.
- **Accessible and fast**: Semantic HTML (including native `&amp;lt;details&amp;gt;`/`&amp;lt;summary&amp;gt;` for the card reveal), keyboard-navigable, works on slow connections.

### Scale
~250 species, ~750 sound cards total. The full catalog JSON (~100KB) can be loaded at once in the browser — no pagination needed.

---

## 2. User Experience

### 2.1 First visit (home page)

A new user (no data in IndexedDB, no username) sees the **landing/onboarding view** instead of an empty review screen:

1. **Brief explanation**: what this app is (&amp;quot;Learn to recognize Dutch bird sounds with spaced repetition&amp;quot;), how it works (listen → guess → rate → the app schedules your next review).
2. **Starter deck info**: &amp;quot;We&#39;ve preselected ~15 common birds to get you started: Koolmees, Merel, Roodborst, Vink...&amp;quot; with a note that these are songs of birds you&#39;ll likely hear in your garden.
3. **How to add more**: &amp;quot;Want to learn waders or raptors? Head to Explore Birds to browse all ~250 Dutch species and pick the sounds you want to study.&amp;quot;
4. **Start button**: prominent &amp;quot;Start Learning&amp;quot; to begin with the starter deck.
5. **Account nudge** (subtle, not blocking): a small note at the bottom — something like: &amp;quot;Want to sync progress across devices? Generate a username — it takes one click, no email needed.&amp;quot; This should not be a modal or banner. Think: a single line of muted text with an inline link.

Once the user has any review data, the home page becomes the review screen.

### 2.2 The review screen

This is the primary screen. It must be fast and distraction-free.

**Use `&amp;lt;details&amp;gt;`/`&amp;lt;summary&amp;gt;` as the card flip mechanism.** This provides native accessible expand/collapse without JS, works with keyboard (Enter/Space), and is announced correctly by screen readers. JS enhances it (keyboard shortcuts, auto-advance, audio control) but the basic reveal works without JS.

**Layout (single column, centered, mobile-first):**

```html
&amp;lt;details id=&amp;quot;card&amp;quot;&amp;gt;
  &amp;lt;summary&amp;gt;
    &amp;lt;!-- Card front --&amp;gt;
    &amp;lt;span class=&amp;quot;progress&amp;quot;&amp;gt;3 of 12 due&amp;lt;/span&amp;gt;
    &amp;lt;button class=&amp;quot;play-btn&amp;quot; aria-label=&amp;quot;Play bird sound&amp;quot;&amp;gt;▶ Play Sound&amp;lt;/button&amp;gt;
    &amp;lt;span class=&amp;quot;hint&amp;quot;&amp;gt;Think about which bird this is, then open to check&amp;lt;/span&amp;gt;
  &amp;lt;/summary&amp;gt;

  &amp;lt;!-- Card back (revealed) --&amp;gt;
  &amp;lt;div class=&amp;quot;answer&amp;quot;&amp;gt;
    &amp;lt;button class=&amp;quot;play-btn&amp;quot; aria-label=&amp;quot;Replay bird sound&amp;quot;&amp;gt;▶ Replay&amp;lt;/button&amp;gt;

    &amp;lt;h2&amp;gt;Koolmees&amp;lt;/h2&amp;gt;
    &amp;lt;p class=&amp;quot;subtitle&amp;quot;&amp;gt;Great Tit (&amp;lt;em&amp;gt;Parus major&amp;lt;/em&amp;gt;)&amp;lt;/p&amp;gt;
    &amp;lt;p class=&amp;quot;sound-type&amp;quot;&amp;gt;Sound type: Song&amp;lt;/p&amp;gt;

    &amp;lt;div class=&amp;quot;bird-image&amp;quot;&amp;gt;
      &amp;lt;!-- Macaulay Library embed --&amp;gt;
      &amp;lt;iframe src=&amp;quot;https://macaulaylibrary.org/asset/XXXXXX/embed&amp;quot;
              width=&amp;quot;320&amp;quot; height=&amp;quot;240&amp;quot;
              title=&amp;quot;Photo of Koolmees (Great Tit)&amp;quot;
              loading=&amp;quot;lazy&amp;quot;&amp;gt;&amp;lt;/iframe&amp;gt;
    &amp;lt;/div&amp;gt;

    &amp;lt;div class=&amp;quot;rating-buttons&amp;quot;&amp;gt;
      &amp;lt;button data-rating=&amp;quot;1&amp;quot;&amp;gt;Again&amp;lt;span class=&amp;quot;interval&amp;quot;&amp;gt;&amp;amp;lt;1m&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;
      &amp;lt;button data-rating=&amp;quot;2&amp;quot;&amp;gt;Hard&amp;lt;span class=&amp;quot;interval&amp;quot;&amp;gt;&amp;amp;lt;10m&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;
      &amp;lt;button data-rating=&amp;quot;3&amp;quot;&amp;gt;Good&amp;lt;span class=&amp;quot;interval&amp;quot;&amp;gt;1d&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;
      &amp;lt;button data-rating=&amp;quot;4&amp;quot;&amp;gt;Easy&amp;lt;span class=&amp;quot;interval&amp;quot;&amp;gt;4d&amp;lt;/span&amp;gt;&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/details&amp;gt;
```

**Behavior:**
- Sound auto-plays when the card appears (with a visible play button to replay).
- Keyboard shortcuts: Space = play/replay, Enter = show answer (opens `&amp;lt;details&amp;gt;`), 1/2/3/4 = rate.
- The four rating buttons show the approximate next review interval beneath them.
- After rating, JS closes the `&amp;lt;details&amp;gt;`, replaces the card content, and opens to the front of the next card. No page reload.
- The answer includes one or more Macaulay Library image embeds (via `&amp;lt;iframe&amp;gt;`) to combine visual and auditory modalities for better memorization. The embed URLs are stored per species in the seed data.
- When the session is done: &amp;quot;You&#39;re done for today! Next review in X hours.&amp;quot; with a link to the card browser.

### 2.3 The card browser (&amp;quot;Explore Birds&amp;quot;)

This is where users choose which cards to study.

**Primary grouping: high-level taxonomic groups**, following the structure used in Collins Bird Guide and similar field guides. These are broad, familiar categories that birders already think in:

- Zwanen, Ganzen en Eenden (with subgroups: Zwanen, Ganzen, Eenden)
- Hoenders
- Duikers
- Futen
- Reigers
- Roofvogels
- Steltlopers
- Meeuwen en Sterns
- Duiven
- Uilen
- Spechten
- Zangvogels (with subgroups: Lijsters, Mezen, Vinken, Gorzen, Kwikstaarten, etc.)
- ...

These groups and subgroups are curated by the developer, inspired by the standard ordering in Dutch field guides. Each species belongs to exactly one group (and optionally one subgroup).

**Structure:**

```
┌──────────────────────────────────────┐
│  Search: [________________] 🔍       │
│                                      │
│  View by: [Taxonomic ▾] [Habitat]   │
│                                      │
│  ─── Mezen (Paridae) ──────────     │
│  [+ Add all common sounds]          │
│                                      │
│  ▸ Koolmees (Great Tit)             │
│    [✓ Song] [✓ Call] [☐ Alarm]      │
│                                      │
│  ▸ Pimpelmees (Blue Tit)            │
│    [✓ Song] [☐ Call]                │
│                                      │
│  ─── Lijsters (Turdidae) ──────     │
│  [+ Add all common sounds]          │
│                                      │
│  ▸ Merel (Blackbird)                │
│    [✓ Song] [☐ Call] [☐ Alarm]      │
│                                      │
└──────────────────────────────────────┘
```

**Key UX decisions:**

- **Primary view: taxonomic groups** as described above. Group headers show the Dutch group name with the Latin family in parentheses.
- **&amp;quot;View by&amp;quot; toggle**: switches between the taxonomic view (default) and a habitat/ecological view (Garden, Woodland, Waterbirds, etc.) which uses a tag-based many-to-many mapping. The ecological view is secondary — useful for &amp;quot;I want to learn garden birds&amp;quot; but not the primary navigation.
- **&amp;quot;Add all common sounds&amp;quot; per group**: one-click to activate the primary song + primary call for every species in a group. This is the key action for &amp;quot;the songbirds are returning, I want to learn those.&amp;quot;
- **Search** filters across Dutch name, English name, Latin name, and family name. Typing &amp;quot;Pari&amp;quot; surfaces all Paridae. Typing &amp;quot;mees&amp;quot; surfaces all tits.
- **Each species expands** to show its available sound types as individual checkboxes (Song, Call, Alarm call, etc.). Each checkbox = one SRS card. Most species have 2-3 sound types; some (like Great Tit) have 5-6.
- **Status indicators** per card: new (never studied), learning (in progress), due (needs review), with small colored dots.

### 2.4 Navigation

Three views, accessible via a simple top nav or bottom tab bar (on mobile):

1. **Review** — the study screen (default/home; shows onboarding for new users)
2. **Explore** — the card browser
3. **Stats** — simple progress overview (cards learned, streak, upcoming reviews graph)

Plus a settings/account area (accessible from a menu icon) for:
- Sync: shows username, &amp;quot;Sync now&amp;quot; button, last sync time
- Account: generate username, log in with existing username
- About/help

### 2.5 Sync flow

- On every rating action, state is saved to IndexedDB immediately.
- A &amp;quot;Sync&amp;quot; button in the nav (or settings) pushes local state to the server and pulls any changes. Visual indicator shows sync status (synced / unsynced changes / error).
- Auto-sync can happen on page load and periodically (every 5 minutes) if the user is logged in, but never blocks the UI.
- **Conflict resolution**: last-write-wins per card. Since this is single-user (one username = one person), conflicts are rare (only if they use two devices simultaneously without syncing). This is acceptable.

---

## 3. Data Model

### 3.1 Bird data (read-only, shipped with the app)

```sql
-- The canonical bird species list
CREATE TABLE species (
    id              INTEGER PRIMARY KEY,
    dutch_name      TEXT NOT NULL,           -- &amp;quot;Koolmees&amp;quot;
    english_name    TEXT NOT NULL,           -- &amp;quot;Great Tit&amp;quot;
    latin_name      TEXT NOT NULL,           -- &amp;quot;Parus major&amp;quot;
    family_latin    TEXT NOT NULL,           -- &amp;quot;Paridae&amp;quot;
    family_dutch    TEXT NOT NULL,           -- &amp;quot;Mezen&amp;quot;
    sort_order      INTEGER NOT NULL,        -- taxonomic sort order (IOC)
    macaulay_asset_ids TEXT                  -- comma-separated Macaulay Library asset IDs for images
);

-- High-level taxonomic groups (Collins-style)
CREATE TABLE taxonomic_groups (
    id              INTEGER PRIMARY KEY,
    name_dutch      TEXT NOT NULL,           -- &amp;quot;Mezen&amp;quot;
    name_latin      TEXT,                    -- &amp;quot;Paridae&amp;quot; (optional, for display)
    parent_id       INTEGER REFERENCES taxonomic_groups(id),  -- for subgroups
    sort_order      INTEGER NOT NULL
);

CREATE TABLE species_taxonomic_group (
    species_id      INTEGER NOT NULL REFERENCES species(id),
    group_id        INTEGER NOT NULL REFERENCES taxonomic_groups(id),
    PRIMARY KEY (species_id, group_id)
);

-- Ecological/habitat groupings (secondary, many-to-many)
CREATE TABLE habitat_groups (
    id              INTEGER PRIMARY KEY,
    name            TEXT NOT NULL,           -- &amp;quot;Garden Birds&amp;quot;
    slug            TEXT NOT NULL UNIQUE,    -- &amp;quot;garden&amp;quot;
    sort_order      INTEGER NOT NULL
);

CREATE TABLE species_habitat (
    species_id      INTEGER NOT NULL REFERENCES species(id),
    group_id        INTEGER NOT NULL REFERENCES habitat_groups(id),
    PRIMARY KEY (species_id, group_id)
);

-- Each card = one species + one sound type
CREATE TABLE cards (
    id              INTEGER PRIMARY KEY,
    species_id      INTEGER NOT NULL REFERENCES species(id),
    sound_type      TEXT NOT NULL,           -- &amp;quot;song&amp;quot;, &amp;quot;call&amp;quot;, &amp;quot;alarm_call&amp;quot;, etc.
    sound_label     TEXT,                    -- human-readable label, e.g. &amp;quot;Teacher-teacher call&amp;quot;
    xc_id           INTEGER NOT NULL,        -- xeno-canto recording ID
    is_primary      BOOLEAN NOT NULL DEFAULT 0,  -- primary song or call (for &amp;quot;add all common&amp;quot;)
    is_starter      BOOLEAN NOT NULL DEFAULT 0,  -- part of the starter deck
    description     TEXT                     -- optional note about this recording
);

CREATE INDEX idx_cards_species ON cards(species_id);
```

Note: `species_taxonomic_group` is technically one-to-one (each species in exactly one group), but using a junction table keeps the schema consistent and allows for edge cases.

This data is bundled as a SQLite file shipped with the app, populated from a `birds.json` seed file on first deploy.

### 3.2 User data (server-side, synced)

```sql
-- Users identified only by generated username
CREATE TABLE users (
    id              INTEGER PRIMARY KEY,
    username        TEXT NOT NULL UNIQUE,    -- &amp;quot;forest-warbler-7291&amp;quot;
    created_at      TEXT NOT NULL DEFAULT (datetime(&#39;now&#39;))
);

-- Which cards a user has activated (chosen to study)
CREATE TABLE user_cards (
    user_id         INTEGER NOT NULL REFERENCES users(id),
    card_id         INTEGER NOT NULL REFERENCES cards(id),
    active          BOOLEAN NOT NULL DEFAULT 1,
    updated_at      TEXT NOT NULL DEFAULT (datetime(&#39;now&#39;)),
    PRIMARY KEY (user_id, card_id)
);

-- SRS state per card per user
CREATE TABLE reviews (
    user_id         INTEGER NOT NULL REFERENCES users(id),
    card_id         INTEGER NOT NULL REFERENCES cards(id),
    ease_factor     REAL NOT NULL DEFAULT 2.5,
    interval_days   REAL NOT NULL DEFAULT 0,
    repetitions     INTEGER NOT NULL DEFAULT 0,
    due_at          TEXT NOT NULL,           -- ISO 8601 datetime
    last_reviewed   TEXT,                    -- ISO 8601 datetime
    updated_at      TEXT NOT NULL DEFAULT (datetime(&#39;now&#39;)),
    PRIMARY KEY (user_id, card_id)
);

CREATE INDEX idx_reviews_due ON reviews(user_id, due_at);

-- Review log for stats and potential algorithm improvements
CREATE TABLE review_log (
    id              INTEGER PRIMARY KEY,
    user_id         INTEGER NOT NULL REFERENCES users(id),
    card_id         INTEGER NOT NULL REFERENCES cards(id),
    rating          INTEGER NOT NULL,        -- 1=Again, 2=Hard, 3=Good, 4=Easy
    ease_factor     REAL NOT NULL,           -- ease factor AFTER this review
    interval_days   REAL NOT NULL,           -- interval AFTER this review
    reviewed_at     TEXT NOT NULL DEFAULT (datetime(&#39;now&#39;))
);
```

### 3.3 Browser-side storage (IndexedDB)

The browser stores a mirror of the user&#39;s `user_cards`, `reviews`, and `review_log` tables. The schema is identical, plus:

```
sync_status: &amp;quot;synced&amp;quot; | &amp;quot;pending&amp;quot;  -- per record
last_sync: ISO 8601 datetime       -- global
```

All writes go to IndexedDB first. Sync pushes `pending` records to the server and pulls the latest state.

---

## 4. SRS Algorithm (SM-2, abstracted)

### 4.1 Interface

Define a clear interface so the algorithm can be swapped later (e.g., to FSRS):

```python
# srs/algorithm.py

from dataclasses import dataclass
from enum import IntEnum

class Rating(IntEnum):
    AGAIN = 1
    HARD = 2
    GOOD = 3
    EASY = 4

@dataclass
class CardState:
    ease_factor: float      # &amp;gt;= 1.3
    interval_days: float    # days until next review
    repetitions: int        # consecutive correct answers

@dataclass
class ReviewResult:
    new_state: CardState
    next_due_delta_days: float  # how many days from now until next review

class SRSAlgorithm:
    &amp;quot;&amp;quot;&amp;quot;Abstract base. Swap implementations without touching the rest of the app.&amp;quot;&amp;quot;&amp;quot;
    def review(self, state: CardState, rating: Rating) -&amp;gt; ReviewResult:
        raise NotImplementedError

    def preview_intervals(self, state: CardState) -&amp;gt; dict[Rating, float]:
        &amp;quot;&amp;quot;&amp;quot;Return the interval for each rating option (for display on buttons).&amp;quot;&amp;quot;&amp;quot;
        raise NotImplementedError

class SM2Algorithm(SRSAlgorithm):
    def review(self, state: CardState, rating: Rating) -&amp;gt; ReviewResult:
        ...  # Standard SM-2 implementation

    def preview_intervals(self, state: CardState) -&amp;gt; dict[Rating, float]:
        ...
```

### 4.2 SM-2 logic (for reference)

- **Again (1)**: Reset repetitions to 0, interval to 1 minute (for re-learning within session). Decrease ease factor by 0.2 (minimum 1.3).
- **Hard (2)**: If first review, interval = 1 day. Otherwise, interval = previous interval × 1.2. Decrease ease factor by 0.15.
- **Good (3)**: If first, 1 day. If second, 6 days. Otherwise, interval × ease factor.
- **Easy (4)**: Like Good but multiply interval by an additional 1.3. Increase ease factor by 0.15.

### 4.3 JavaScript mirror

The same algorithm must be implemented in JS for client-side use. Both implementations must be tested against the same set of test vectors to guarantee they produce identical results.

---

## 5. Architecture

### 5.1 Overview

```
┌──────────────┐         ┌──────────────────────┐
│   Browser    │  sync   │   Flask Server        │
│              │◄───────►│                       │
│  HTML/CSS/JS │  JSON   │  /api/sync            │
│  IndexedDB   │         │  /api/register        │
│  SM-2 (JS)   │         │  /api/audio/&amp;lt;xc_id&amp;gt;   │
│  &amp;lt;details&amp;gt;   │         │                       │
│  htmx (nav)  │         │  SQLite (bird data    │
│              │         │   + user data)        │
└──────────────┘         └──────────────────────┘
```

### 5.2 Server (Python + Flask)

**Routes:**

| Route | Method | Description |
|---|---|---|
| `/` | GET | Home / review screen (HTML), or onboarding for new users |
| `/explore` | GET | Card browser (HTML) |
| `/stats` | GET | Stats page (HTML) |
| `/api/register` | POST | Generate username, return it |
| `/api/sync` | POST | Accept local changes, return server state |
| `/api/audio/&amp;lt;xc_id&amp;gt;` | GET | Serve pre-downloaded audio file |
| `/api/birds` | GET | Full bird/card catalog as JSON (for browser cache) |

**Key decisions:**
- Pages are server-rendered HTML. Use htmx for interactions that benefit from it (e.g., toggling cards in the browser, search filtering), but the review screen is pure client-side JS (no round-trip per card flip).
- Audio files: pre-downloaded during the seed step and served as static files from `static/audio/&amp;lt;xc_id&amp;gt;.mp3`. No runtime dependency on xeno-canto.
- Single SQLite database file for both bird data and user data. Simple to backup.

### 5.3 Client-side JS

Keep it small and readable. No framework. Vanilla JS organized into a few modules:

```
static/js/
  srs.js          -- SM-2 algorithm (mirror of Python version)
  storage.js      -- IndexedDB wrapper (get/set card state, queue changes)
  sync.js         -- push/pull sync with server
  review.js       -- review screen logic (play, reveal, rate, next)
  explore.js      -- card browser interactions (supplementing htmx)
  audio.js        -- audio playback helper (preloading, error handling)
```

Total JS should be well under 2000 lines. No build step, no bundler. Use ES modules (`&amp;lt;script type=&amp;quot;module&amp;quot;&amp;gt;`).

### 5.4 htmx usage

Use htmx for:
- Card browser: filtering by group, search, toggling card activation (PATCH requests that swap HTML fragments).
- Stats page: loading charts/data.
- &amp;quot;View by&amp;quot; toggle in the card browser.

Do NOT use htmx for:
- The review screen. All review logic is client-side JS operating on IndexedDB. Zero network requests during a review session (except audio and image loading).

### 5.5 CSS

A single `style.css` file. No framework. Use CSS custom properties for theming. Mobile-first. Target ~300-500 lines. A small CSS reset (e.g., Andy Bell&#39;s modern reset) as a base.

---

## 6. Audio Handling

### 6.1 Source
Xeno-canto recordings, referenced by ID. The developer maintains a curated list of high-quality recordings (one per card) in the seed data.

### 6.2 Storage
Pre-download all audio files during a build/seed step:

```bash
# seed/download_audio.py (pseudocode)
for card in cards:
    download(f&amp;quot;https://xeno-canto.org/{card.xc_id}/download&amp;quot;)
    -&amp;gt; static/audio/{xc_id}.mp3
```

Serve as static files. No runtime dependency on xeno-canto.

### 6.3 Browser caching
Set long `Cache-Control` headers on audio files (they never change for a given xc_id). No service worker needed for v1.

### 6.4 Preloading
When a review session starts, preload the audio for the next 2-3 cards in the queue using `new Audio(url)`.

---

## 7. Sync Protocol

### 7.1 Registration

```
POST /api/register
Response: { &amp;quot;username&amp;quot;: &amp;quot;forest-warbler-7291&amp;quot; }
```

Username format: `{adjective}-{bird}-{4 digits}`. Generated server-side from a curated word list. Stored in localStorage on the client.

To &amp;quot;log in&amp;quot; on another device, user simply enters their username. No password. Acceptable because:
- The only data at risk is SRS progress — low sensitivity.
- Usernames are hard to guess (adjective-bird-4digits = millions of combinations).
- Tradeoff is explicitly toward simplicity.

### 7.2 Sync endpoint

```
POST /api/sync
Headers: X-Username: forest-warbler-7291
Body: {
    &amp;quot;last_sync&amp;quot;: &amp;quot;2025-01-15T10:00:00Z&amp;quot;,
    &amp;quot;changes&amp;quot;: {
        &amp;quot;user_cards&amp;quot;: [
            { &amp;quot;card_id&amp;quot;: 42, &amp;quot;active&amp;quot;: true, &amp;quot;updated_at&amp;quot;: &amp;quot;...&amp;quot; },
            ...
        ],
        &amp;quot;reviews&amp;quot;: [
            { &amp;quot;card_id&amp;quot;: 42, &amp;quot;ease_factor&amp;quot;: 2.5, &amp;quot;interval_days&amp;quot;: 4.0,
              &amp;quot;repetitions&amp;quot;: 3, &amp;quot;due_at&amp;quot;: &amp;quot;...&amp;quot;, &amp;quot;updated_at&amp;quot;: &amp;quot;...&amp;quot; },
            ...
        ],
        &amp;quot;review_log&amp;quot;: [
            { &amp;quot;card_id&amp;quot;: 42, &amp;quot;rating&amp;quot;: 3, &amp;quot;ease_factor&amp;quot;: 2.5,
              &amp;quot;interval_days&amp;quot;: 4.0, &amp;quot;reviewed_at&amp;quot;: &amp;quot;...&amp;quot; },
            ...
        ]
    }
}

Response: {
    &amp;quot;server_time&amp;quot;: &amp;quot;2025-01-15T12:00:00Z&amp;quot;,
    &amp;quot;changes&amp;quot;: {
        &amp;quot;user_cards&amp;quot;: [...],
        &amp;quot;reviews&amp;quot;: [...]
    }
}
```

**Conflict resolution**: For `reviews` and `user_cards`, compare `updated_at` — latest wins. For `review_log`, append-only (no conflicts).

---

## 8. Accessibility

- **Card reveal uses `&amp;lt;details&amp;gt;`/`&amp;lt;summary&amp;gt;`**: native keyboard support (Enter/Space), announced by screen readers as expandable, works without JS.
- All interactive elements are real `&amp;lt;button&amp;gt;` and `&amp;lt;a&amp;gt;` elements (not divs).
- Audio player uses a visible `&amp;lt;button&amp;gt;` with `aria-label=&amp;quot;Play bird sound&amp;quot;`. Not relying on autoplay alone.
- Rating buttons have aria-labels: `aria-label=&amp;quot;Again — review in 1 minute&amp;quot;`.
- Card browser checkboxes are real `&amp;lt;input type=&amp;quot;checkbox&amp;quot;&amp;gt;` with associated `&amp;lt;label&amp;gt;`.
- Skip-to-content link on every page.
- Focus management: after rating a card, focus moves to the play button of the next card.
- Sufficient color contrast (WCAG AA minimum).
- Keyboard shortcuts documented in a help modal; they don&#39;t conflict with screen reader keys.
- `prefers-reduced-motion`: disable any transitions.
- `prefers-color-scheme`: support dark mode via CSS custom properties.
- Macaulay Library iframes include descriptive `title` attributes.

---

## 9. Project Structure

```
bird-srs/
├── pyproject.toml          # uv/PEP 621 project config
├── uv.lock                 # lockfile
├── app.py                  # Flask app, routes, API endpoints
├── config.py               # Configuration (DB path, audio path, etc.)
├── srs/
│   ├── __init__.py
│   ├── algorithm.py        # SM-2 implementation + abstract interface
│   └── models.py           # DB access functions (no ORM, plain SQL)
├── seed/
│   ├── birds.json          # Canonical bird + card data
│   ├── seed_db.py          # Create/populate SQLite from birds.json
│   └── download_audio.py   # Fetch audio from xeno-canto
├── static/
│   ├── css/
│   │   └── style.css
│   ├── js/
│   │   ├── srs.js
│   │   ├── storage.js
│   │   ├── sync.js
│   │   ├── review.js
│   │   ├── explore.js
│   │   └── audio.js
│   └── audio/              # Pre-downloaded .mp3 files (gitignored)
│       └── 12345.mp3
├── templates/
│   ├── base.html           # Shared layout, nav, head
│   ├── home.html           # Onboarding for new users
│   ├── review.html         # Review screen
│   ├── explore.html        # Card browser
│   ├── explore_partials/   # htmx fragments for card browser
│   │   ├── species_list.html
│   │   └── species_row.html
│   └── stats.html
├── tests/
│   ├── test_algorithm.py   # SM-2 test vectors (shared with JS tests)
│   ├── test_sync.py        # Sync conflict resolution tests
│   └── test_api.py         # API endpoint tests
├── Dockerfile
├── docker-compose.yml
└── README.md
```

---

## 10. Deployment

### Docker

```dockerfile
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . .
RUN uv run python seed/seed_db.py
EXPOSE 8000
CMD [&amp;quot;uv&amp;quot;, &amp;quot;run&amp;quot;, &amp;quot;gunicorn&amp;quot;, &amp;quot;-w&amp;quot;, &amp;quot;2&amp;quot;, &amp;quot;-b&amp;quot;, &amp;quot;0.0.0.0:8000&amp;quot;, &amp;quot;app:app&amp;quot;]
```

```yaml
# docker-compose.yml
services:
  web:
    build: .
    ports:
      - &amp;quot;8000:8000&amp;quot;
    volumes:
      - ./data:/app/data                  # SQLite DB persists here
      - ./static/audio:/app/static/audio  # Audio files
    restart: unless-stopped
```

### Backup

```bash
# Cron job: daily SQLite backup
sqlite3 /app/data/bird_srs.db &amp;quot;.backup /backups/bird_srs_$(date +%F).db&amp;quot;
```

SQLite is a single file. Backup = copy the file (or use `.backup` for a safe hot copy).

### Reverse proxy

Caddy for HTTPS (automatic Let&#39;s Encrypt):

```
birdsrs.example.com {
    reverse_proxy localhost:8000
}
```

---

## 11. Dependencies

### Python (via uv)
- Flask
- gunicorn

That&#39;s it. No ORM, no migration tool, no task queue. SQLite is in the stdlib.

### JavaScript
- htmx (~14KB gzipped, vendored in static/)
- No other dependencies

### Seed/build time (dev dependency)
- requests (for downloading xeno-canto audio)

---

## 12. Implementation Order (suggested)

### Phase 1: Core review loop
1. Set up project structure with `uv init`, Flask, SQLite schema, seed script with ~15 starter birds.
2. Implement SM-2 in Python (with tests).
3. Build the review screen: server-rendered HTML with `&amp;lt;details&amp;gt;`/`&amp;lt;summary&amp;gt;` + client-side JS for audio playback, rating, and card progression.
4. Implement IndexedDB storage for SRS state.
5. Port SM-2 to JS (test against same vectors as Python version).
6. End-to-end: user can review starter deck entirely client-side.

### Phase 2: Card browser
7. Build the explore page: species list grouped by taxonomic groups (Collins-style).
8. Implement card activation (checkboxes → IndexedDB), including &amp;quot;add all common sounds&amp;quot; per group.
9. Add search/filter and habitat &amp;quot;view by&amp;quot; toggle with htmx.
10. Connect activated cards to the review queue.

### Phase 3: Sync &amp;amp; accounts
11. Registration endpoint (generate username).
12. Sync endpoint (push/pull with last-write-wins).
13. Client-side sync logic.
14. Login on another device.

### Phase 4: Polish
15. Onboarding home page for new users.
16. Stats page.
17. Dark mode.
18. Audio preloading.
19. Keyboard shortcuts.
20. Docker setup and deployment.
21. Macaulay Library image embeds in card answers.

---

## 13. Resolved &amp;amp; Remaining Notes

**Resolved from earlier discussion:**
- ~250 species, ~750 cards. Full catalog loaded at once (no pagination).
- Audio licensing handled by developer.
- Taxonomic groups curated by developer, inspired by Collins Bird Guide.
- Most species have 2-6 sound types (mode 2, median 2-3, mean ~3). Always show expand per species since nearly all have multiple sounds.
- No service worker for v1 — app needs internet for audio/images but reviews work offline.
- Single recording per card is fine for v1.

**Remaining for the developer:**
- Finalize the `birds.json` seed data (species, cards, xc_ids, Macaulay asset IDs, group assignments).
- Curate the taxonomic group hierarchy and habitat group tags.
- Decide on the exact starter deck species.
- Verify Macaulay Library embed format and terms of use.&lt;/code&gt;&lt;/pre&gt;]]></description>
    </item>
    
    <item>
      <title>Breathe With Me</title>
      <link>https://rensoliemans.nl/en/writing/breathe-with-me/</link>
      <guid>https://rensoliemans.nl/en/writing/breathe-with-me/</guid>
      <pubDate>Mon, 08 Dec 2025 00:00:00 +0000</pubDate>
      <description><![CDATA[&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Breathe_(The_Prodigy_song)&quot;&gt;Breathe&lt;/a&gt; is a song by the Prodigy in which their vocalist &lt;a href=&quot;https://en.wikipedia.org/wiki/Keith_Flint&quot;&gt;Keith Flint&lt;/a&gt; calls upon us to &amp;quot;Breathe with me&amp;quot;. Flint died in 2019, and this made me wonder, do I still breathe with him in a way?&lt;/p&gt;
&lt;p&gt;Flint died at age 49. Assuming he breathed at &lt;a href=&quot;https://en.wikipedia.org/wiki/Respiratory_rate&quot;&gt;the same rate as an average human&lt;/a&gt;, he took around 350 million breaths in his life&lt;sup&gt;&lt;a href=&quot;#fn-1&quot; id=&quot;fnref-1&quot;&gt;1&lt;/a&gt;&lt;/sup&gt;. The volume of a regular breath is about 500mL of air&lt;sup&gt;&lt;a href=&quot;#fn-2&quot; id=&quot;fnref-2&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;, so Flint breathed 175 million litres of air overall, or about 215 tonnes&lt;sup&gt;&lt;a href=&quot;#fn-3&quot; id=&quot;fnref-3&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;. Since the &lt;a href=&quot;https://en.wikipedia.org/wiki/Atmosphere_of_Earth&quot;&gt;total mass of the atmosphere is \(5.15 \times 10^{18}\) kg&lt;/a&gt;, about one part in every \(2 \times 10^{13}\) parts of the atmosphere has been in Flint&#39;s lungs&lt;sup&gt;&lt;a href=&quot;#fn-4&quot; id=&quot;fnref-4&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;In order to find out whether a breath of ours contains some part that has been in Flint&#39;s lungs, we need to see how many molecules of air we breathe. If its greater than \(2 \times 10^{13}\), we know that each breath contains at least one molecule of air that has been in Flint&#39;s lungs. We find that, &lt;a href=&quot;https://en.wikipedia.org/wiki/Density_of_air&quot;&gt;with an average molar mass of 0.029 kg/mol&lt;/a&gt;, we breathe about \(10^{23}\) molecules of air per breath&lt;sup&gt;&lt;a href=&quot;#fn-5&quot; id=&quot;fnref-5&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;, that&#39;s nearly &lt;em&gt;5 billion molecules&lt;/em&gt; that were once in Flint&#39;s lungs with every single breath we take.&lt;/p&gt;
&lt;p&gt;This is wild. Each time you breathe, you are breathing air that has been breathed by &lt;em&gt;every person that has ever lived&lt;/em&gt;. &amp;quot;Breathe with me,&amp;quot; indeed.&lt;/p&gt;
&lt;h2&gt;Footnotes&lt;/h2&gt;
&lt;div class=&quot;footnote&quot; id=&quot;fn-1&quot;&gt;&lt;sup&gt;1&lt;/sup&gt; According to &lt;a href=&quot;https://en.wikipedia.org/wiki/Respiratory_rate&quot;&gt;Wikipedia&lt;/a&gt;, an adult typically breathes for about 12-15 breaths per minute. Children breathe more often, but also take smaller breaths, so I&#39;m going to assume Flint breathed like an adult for his entire life. He lived for 18,065 days, so he breathed \(18,065 \times 24 \times 60 \times 13.5 \approx 3.5 \times 10^{8}\) times. &lt;a href=&quot;#fnref-1&quot;&gt;↩&lt;/a&gt;&lt;/div&gt;
&lt;div class=&quot;footnote&quot; id=&quot;fn-2&quot;&gt;&lt;sup&gt;2&lt;/sup&gt; &lt;a href=&quot;https://archive.org/details/principlesofanat0000tort_f3y5&quot;&gt;Principles of anatomy &amp;amp; physiology by Gerard J. Tortora and Bryan H. Derrickson&lt;/a&gt;, page 874. &lt;a href=&quot;#fnref-2&quot;&gt;↩&lt;/a&gt;&lt;/div&gt;
&lt;div class=&quot;footnote&quot; id=&quot;fn-3&quot;&gt;&lt;sup&gt;3&lt;/sup&gt; Air weighs about 1.225 kg/m^3. So \(0.5 \times 351,183,600 \times 10^{6} \mathrm{L} \times 1.225 \mathrm{kg}/\mathrm{m}^3 \approx 215,100 \mathrm{kg}\). &lt;a href=&quot;#fnref-3&quot;&gt;↩&lt;/a&gt;&lt;/div&gt;
&lt;div class=&quot;footnote&quot; id=&quot;fn-4&quot;&gt;&lt;sup&gt;4&lt;/sup&gt; \(215,100/(5.15 \times 10^{18}) \approx 4.18 \times 10^{−14}\), which is the fraction of the atmosphere that has been in Flint&#39;s lungs. The reciprocal of that, \(1 / 4.18 \times 10^{-14} \approx 2.39 \times 10^{13}\) means &amp;quot;one in every \(2.39 \times 10^{13}\) atmospheric parts has been in Flint&#39;s lungs&amp;quot;. This makes the perhaps unrealistic assumption that each breath is a totally new volume of air in our atmosphere. In reality our number will be lower, but not orders of magnitudes lower. &lt;a href=&quot;#fnref-4&quot;&gt;↩&lt;/a&gt;&lt;/div&gt;
&lt;div class=&quot;footnote&quot; id=&quot;fn-5&quot;&gt;&lt;sup&gt;5&lt;/sup&gt; Using a density of 1.225 kg/m^3, each breath weighs \(500 \mathrm{mL} \times 1.225 \mathrm{kg}/\mathrm{m}^3 = 0.0006125\mathrm{kg}\). So, each breath contains \(0.0006125 \mathrm{kg} / (0.0289652 \mathrm{kg}/\mathrm{mol}) \times \mathrm{avogadro} \approx 1.27 \times 10^{23}\) molecules. &lt;a href=&quot;#fnref-5&quot;&gt;↩&lt;/a&gt;&lt;/div&gt;
&lt;div class=&quot;footnote&quot; id=&quot;fn-6&quot;&gt;&lt;sup&gt;6&lt;/sup&gt; At sea level, &lt;a href=&quot;https://en.wikipedia.org/wiki/Density_of_air&quot;&gt;air weighs 1.225kg/m^3&lt;/a&gt; &lt;a href=&quot;#fnref-6&quot;&gt;↩&lt;/a&gt;&lt;/div&gt;]]></description>
    </item>
    
    <item>
      <title>Projects</title>
      <link>https://rensoliemans.nl/en/projects/</link>
      <guid>https://rensoliemans.nl/en/projects/</guid>
      
      <description><![CDATA[&lt;dl&gt;&lt;dt&gt;&lt;a href=&quot;https://kwantumkwestie.nl&quot;&gt;kwantumkwestie.nl&lt;/a&gt;&lt;/dt&gt;&lt;dd&gt;a mini website that plays around with some philosophical ideas that arise from quantum mechanics.&lt;/dd&gt;&lt;dt&gt;&lt;a href=&quot;https://vogelgeluidjes.nl&quot;&gt;vogelgeluidjes.nl&lt;/a&gt;&lt;/dt&gt;&lt;dd&gt;a website that helps you learn to recognize bird sounds using spaced repetition.&lt;/dd&gt;&lt;dt&gt;&lt;a href=&quot;https://rensoliemans.nl/aoc&quot;&gt;My Advent of Code solutions&lt;/a&gt;&lt;/dt&gt;&lt;dd&gt;literate programming solutions written in Clojure.&lt;/dd&gt;&lt;dt&gt;&lt;a href=&quot;https://addons.mozilla.org/en-US/firefox/addon/anna-goodreads/&quot;&gt;anna-goodreads&lt;/a&gt;&lt;/dt&gt;&lt;dd&gt;a Firefox extension that adds annas-archive links next to books on Goodreads.&lt;/dd&gt;&lt;dt&gt;&lt;a href=&quot;https://addons.mozilla.org/en-US/firefox/addon/vogelchecklist-vergelijker/&quot;&gt;vogelchecklist-vergelijker&lt;/a&gt;&lt;/dt&gt;&lt;dd&gt;a Firefox extension that helps compare &lt;a href=&quot;https://vogelchecklist.nl/bigyear&quot;&gt;vogelchecklist.nl/bigyear&lt;/a&gt; lists.&lt;/dd&gt;&lt;dt&gt;&lt;a href=&quot;https://codeberg.org/RensOliemans/randomshit&quot;&gt;randomshit&lt;/a&gt;&lt;/dt&gt;&lt;dd&gt;a collection of (mainly python) programs that I have created over the years to answer questions like &amp;quot;how many times do you have to riffle shuffle a deck to get it properly sorted?&amp;quot;, or &amp;quot;what is the average occupancy rate of parking lots in Amsterdam?&amp;quot;&lt;/dd&gt;&lt;/dl&gt;]]></description>
    </item>
    
  </channel>
</rss>