<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Uya</title>
    <description>The latest articles on DEV Community by Uya (@uya0526design).</description>
    <link>https://dev.to/uya0526design</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3947868%2F3e2ac0d9-ff74-4790-9883-956f75a1964e.png</url>
      <title>DEV Community: Uya</title>
      <link>https://dev.to/uya0526design</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/uya0526design"/>
    <language>en</language>
    <item>
      <title>Building a Countdown Timer CLI in Python — time, finally, mock, and Testing Exceptions</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Sun, 28 Jun 2026 11:24:34 +0000</pubDate>
      <link>https://dev.to/uya0526design/building-a-countdown-timer-cli-in-python-time-finally-mock-and-testing-exceptions-da4</link>
      <guid>https://dev.to/uya0526design/building-a-countdown-timer-cli-in-python-time-finally-mock-and-testing-exceptions-da4</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is my tenth article as a Java engineer learning TypeScript and Python from scratch.&lt;/p&gt;

&lt;p&gt;This is the third project in my Python series. The first was a &lt;strong&gt;weight tracker CLI&lt;/strong&gt; (type hints, pure functions, separating I/O from logic), and the second was a &lt;strong&gt;password generator CLI&lt;/strong&gt; (&lt;code&gt;string&lt;/code&gt;, &lt;code&gt;random&lt;/code&gt;, &lt;code&gt;any&lt;/code&gt;, and a testing strategy for randomness).&lt;/p&gt;

&lt;p&gt;This time I built a &lt;strong&gt;countdown timer CLI&lt;/strong&gt;. You pick a number of seconds from a menu, and it counts down one second at a time. Where the previous projects were about "calculating or generating a value," this one centers on &lt;strong&gt;time control&lt;/strong&gt; and &lt;strong&gt;exception handling&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What I focused on this time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stepping through a process one second at a time with &lt;code&gt;time.sleep()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;reverse loop&lt;/strong&gt; with &lt;code&gt;range(n, 0, -1)&lt;/code&gt; (the countdown)&lt;/li&gt;
&lt;li&gt;Overwriting the &lt;strong&gt;same line in place&lt;/strong&gt; with &lt;code&gt;\r&lt;/code&gt; (carriage return) plus &lt;code&gt;end=""&lt;/code&gt; plus &lt;code&gt;flush=True&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Exception design with &lt;code&gt;try / except / finally&lt;/code&gt; (catching &lt;code&gt;KeyboardInterrupt&lt;/code&gt; and a generic &lt;code&gt;Exception&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Using &lt;code&gt;finally&lt;/code&gt; to gather "the message that must appear on success, interruption, or error alike"&lt;/li&gt;
&lt;li&gt;Mocking (swapping out) &lt;code&gt;time.sleep&lt;/code&gt; in tests to raise exceptions on purpose&lt;/li&gt;
&lt;li&gt;Capturing stdout as a string with &lt;code&gt;capsys&lt;/code&gt; for verification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As always, I write honestly about where I got stuck, what I thought through, and what I asked AI for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Where this sits in the series&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the third article in my Python series. So far the processes always ran to the end, but this time I step into a new theme: how to design and test a process that &lt;strong&gt;might stop partway&lt;/strong&gt;, like a user pressing Ctrl+C mid-run.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  My Learning Style (AI Transparency)
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Learning companions &amp;amp; how this article is written&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;Claude Pro&lt;/strong&gt; (design discussions, Q&amp;amp;A, and article drafting) and &lt;strong&gt;Cursor Pro&lt;/strong&gt; (coding support).&lt;/p&gt;

&lt;p&gt;Division of roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tech selection, design, implementation, and code verification&lt;/strong&gt; → me&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Article structure, outline, draft prose, and translation&lt;/strong&gt; → in collaboration with Claude&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All content is checked and revised by me before publishing&lt;/strong&gt; → me&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;For the code, I set myself these rules: I write the code myself (I never ask AI to write code for me), I use AI for hints, spec clarification, and bug spotting, and I make sure I understand &lt;em&gt;why&lt;/em&gt; before moving on. In this article I clearly separate "what I implemented myself" from "what I asked AI for." My line is "&lt;strong&gt;the thinking and decisions are mine, the wording is AI-assisted, and I verify the final content myself.&lt;/strong&gt;" This isn't an apology — just stating the facts.&lt;/p&gt;




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

&lt;p&gt;A CLI tool where you pick a menu number and count down 60 seconds, 30 seconds, or a custom number of seconds. While counting, the number decreases &lt;strong&gt;overwriting the same line&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;----------------------------------
Welcome to the countdown timer!
1. Count down 60 seconds
2. Count down 30 seconds
3. Count down custom seconds
4. Exit
----------------------------------
Enter your choice: 3
Enter the number of seconds: 5
1 seconds remaining...Time's up!
Countdown finished.
----------------------------------
Welcome to the countdown timer!
1. Count down 60 seconds
2. Count down 30 seconds
3. Count down custom seconds
4. Exit
----------------------------------
Enter your choice: 4
Exiting the program...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example only shows &lt;code&gt;1 seconds remaining...&lt;/code&gt;, but that's because &lt;code&gt;5, 4, 3, 2, 1&lt;/code&gt; are all &lt;strong&gt;overwriting the same line&lt;/strong&gt; (the mechanism is explained later). Press Ctrl+C during the count, and it shows an interruption message and returns to the menu.&lt;/p&gt;

&lt;p&gt;📦 Repository: &lt;a href="https://github.com/uya0526-design/countdown_timer_py" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/countdown_timer_py&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  File Structure and Tech Stack
&lt;/h2&gt;

&lt;p&gt;In the previous project (password generator) I split the &lt;code&gt;logic&lt;/code&gt; from the &lt;code&gt;entry point&lt;/code&gt; into separate files, so I kept the same approach here. The countdown body goes in &lt;code&gt;timer.py&lt;/code&gt;, and the menu and entry point go in &lt;code&gt;main.py&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;countdown_timer_py/
├── src/
│   ├── __init__.py
│   ├── main.py          # entry point
│   └── timer.py         # countdown logic
├── tests/
│   ├── __init__.py
│   └── test_timer.py    # unit tests
├── requirements.txt     # dependencies
├── LEARNING_LOG.md      # learning log
└── README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tech stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python&lt;/strong&gt; 3.12&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pytest&lt;/strong&gt; 9.0.3&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I split the countdown logic (&lt;code&gt;timer.py&lt;/code&gt;) from the menu I/O (&lt;code&gt;main.py&lt;/code&gt;) because I wanted to keep the "separation of concerns" I'd learned in earlier projects. In Java terms, it feels close to separating the class that holds the logic from the startup class that holds &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Implemented Myself
&lt;/h2&gt;

&lt;h3&gt;
  
  
  timer.py — A reverse loop and time.sleep to step one second at a time
&lt;/h3&gt;

&lt;p&gt;This is the heart of the countdown. I run &lt;code&gt;range&lt;/code&gt; backward and wait one second on each pass with &lt;code&gt;time.sleep(1)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;count_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;Count down the specified seconds&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; seconds remaining...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Time&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s up!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;KeyboardInterrupt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Countdown interrupted by user.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;An error occurred: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Countdown finished.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three points here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Looping backward with &lt;code&gt;range(seconds, 0, -1)&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Passing &lt;code&gt;-1&lt;/code&gt; as the &lt;code&gt;step&lt;/code&gt; in &lt;code&gt;range(start, stop, step)&lt;/code&gt; makes the loop &lt;strong&gt;count down&lt;/strong&gt;: &lt;code&gt;seconds, seconds-1, ..., 1&lt;/code&gt; (since &lt;code&gt;stop=0&lt;/code&gt; is excluded, it stops at &lt;code&gt;1&lt;/code&gt;). It's a direct replacement for Java's &lt;code&gt;for (int i = seconds; i &amp;gt; 0; i--)&lt;/code&gt;, so for someone with a Java background it was actually a familiar way to write it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Waiting one second with &lt;code&gt;time.sleep(1)&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;time.sleep(seconds)&lt;/code&gt; pauses the process for that many seconds. That's what creates the feel of "the number drops once per second." It's the counterpart of Java's &lt;code&gt;Thread.sleep(1000)&lt;/code&gt; (in milliseconds), but Python takes &lt;strong&gt;seconds&lt;/strong&gt;, which was a small but notable difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The loop variable &lt;code&gt;i&lt;/code&gt; is used for display&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the previous password generator I just needed "the number of repetitions," so I used &lt;code&gt;for _ in range(...)&lt;/code&gt; with &lt;code&gt;_&lt;/code&gt;. This time I use &lt;code&gt;i&lt;/code&gt; (the remaining seconds) for display, so I give it a real name. Being able to consciously choose between &lt;code&gt;i&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt; based on "do I use the loop variable or not?" is continuous growth from last time.&lt;/p&gt;

&lt;h3&gt;
  
  
  timer.py — Overwriting the same line with &lt;code&gt;\r&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When I first wrote this naively, the countdown &lt;strong&gt;stacked up vertically&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5 seconds remaining...
4 seconds remaining...
3 seconds remaining...
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That doesn't feel like a timer. What I used here is &lt;code&gt;\r&lt;/code&gt; (carriage return).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; seconds remaining...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The role of each argument:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;\r&lt;/code&gt; ... returns the cursor to the &lt;strong&gt;start of the line&lt;/strong&gt;, so the next output overwrites the same line&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;end=""&lt;/code&gt; ... stops the newline &lt;code&gt;print&lt;/code&gt; adds by default (a newline would break the overwrite)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flush=True&lt;/code&gt; ... flushes the output buffer immediately so it reaches the screen without waiting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With these three together, &lt;code&gt;5, 4, 3, 2, 1&lt;/code&gt; rewrite in place on the same line. I also learned by testing that without &lt;code&gt;flush=True&lt;/code&gt;, the display sometimes doesn't update while &lt;code&gt;sleep&lt;/code&gt; is paused, and it "stutters."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;A common terminal-UI technique&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a standard terminal-UI trick, and apparently it also applies to progress bars and "processing..." spinners. The look of the display is a real part of UX too — that's the mindset I brought to this spot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  timer.py — Designing a "process that may stop partway" with try / except / finally
&lt;/h3&gt;

&lt;p&gt;The part I thought about most this time was exception design. A countdown is a process where "the user may stop it partway with Ctrl+C," so I want to &lt;strong&gt;catch that interruption gracefully instead of crashing with an error&lt;/strong&gt;. So I used a three-layer structure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; seconds remaining...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Time&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s up!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;KeyboardInterrupt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Countdown interrupted by user.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;An error occurred: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Countdown finished.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The roles, organized:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Block&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;try&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The countdown body. Shows &lt;code&gt;Time's up!&lt;/code&gt; if it runs to the end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;except KeyboardInterrupt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Catches a Ctrl+C interruption and shows the interruption message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;except Exception as e&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Catches any other unexpected error and shows a message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;finally&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Always shows the finish message no matter which ending occurs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here my Java experience paid off directly for the first time in a while. The &lt;code&gt;try / catch / finally&lt;/code&gt; structure is almost the same as Java, and the point that &lt;code&gt;finally&lt;/code&gt; "always runs regardless of whether an exception occurred" matches too. The differences are that Java's &lt;code&gt;catch (Exception e)&lt;/code&gt; becomes &lt;code&gt;except Exception as e&lt;/code&gt; in Python, and that &lt;strong&gt;you can catch Ctrl+C as a &lt;code&gt;KeyboardInterrupt&lt;/code&gt; exception&lt;/strong&gt;. Signal handling felt like extra work in Java, so being able to treat this as an ordinary exception was fresh.&lt;/p&gt;

&lt;p&gt;What I focused on in the design was "what to put in &lt;code&gt;finally&lt;/code&gt;." &lt;code&gt;Countdown finished.&lt;/code&gt; is a message I want shown &lt;strong&gt;on every kind of ending&lt;/strong&gt;, so I put it in &lt;code&gt;finally&lt;/code&gt;. Conversely, the "only on success" &lt;code&gt;Time's up!&lt;/code&gt; goes at the end of &lt;code&gt;try&lt;/code&gt;, and the "only on interruption" message goes in &lt;code&gt;except&lt;/code&gt;. The idea of &lt;strong&gt;deciding where to place things by working backward from the requirement "when do I want this shown?"&lt;/strong&gt; clicked for me this time.&lt;/p&gt;

&lt;h3&gt;
  
  
  main.py — The menu loop and catching ValueError
&lt;/h3&gt;

&lt;p&gt;The entry point &lt;code&gt;main.py&lt;/code&gt; runs the menu with &lt;code&gt;while True&lt;/code&gt; and guards the custom-seconds input with &lt;code&gt;try / except&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;count_down&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;Main function&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;----------------------------------&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Welcome to the countdown timer!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1. Count down 60 seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2. Count down 30 seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3. Count down custom seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4. Exit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;----------------------------------&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Enter your choice: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;count_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;count_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Enter the number of seconds: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="nf"&gt;count_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid input. Please enter a valid number of seconds.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Exiting the program...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid choice. Please enter a valid choice.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Input that &lt;code&gt;int()&lt;/code&gt; can't convert raises &lt;code&gt;ValueError&lt;/code&gt; (close to Java's &lt;code&gt;NumberFormatException&lt;/code&gt;), so I catch it and &lt;code&gt;continue&lt;/code&gt; back to the top of the menu. This &lt;code&gt;try / except ValueError&lt;/code&gt; then &lt;code&gt;continue&lt;/code&gt; pattern shows up again and again across the Python series, and it has become second nature.&lt;/p&gt;

&lt;h3&gt;
  
  
  tests/test_timer.py — How to test a "waiting" process and exceptions
&lt;/h3&gt;

&lt;p&gt;The hard parts of testing this time were two: &lt;strong&gt;(1) if &lt;code&gt;time.sleep&lt;/code&gt; really waits, the test is slow&lt;/strong&gt;, and &lt;strong&gt;(2) how do I raise exceptions (Ctrl+C or an error) on purpose?&lt;/strong&gt; I solved these with &lt;code&gt;unittest.mock.patch&lt;/code&gt; and &lt;code&gt;capsys&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;unittest.mock&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.timer&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;count_down&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_count_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capsys&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;The countdown will start for the number of seconds entered&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;count_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1 seconds remaining...Time&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s up!&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Countdown finished.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;capsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readouterr&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_keyboard_interrupt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capsys&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;KeyboardInterrupt: A message will be returned when an exception occurs&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;time.sleep&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;side_effect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;KeyboardInterrupt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;count_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Countdown interrupted by user.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;capsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readouterr&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capsys&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;Exception: A message will be returned when an exception occurs&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;time.sleep&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;side_effect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;count_down&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;An error occurred&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;capsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readouterr&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things I worked out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Capturing stdout with &lt;code&gt;capsys&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;capsys&lt;/code&gt; is a fixture pytest provides, and &lt;code&gt;capsys.readouterr().out&lt;/code&gt; lets me grab "the string that &lt;code&gt;print&lt;/code&gt; produced." A function that returns &lt;code&gt;None&lt;/code&gt; (&lt;code&gt;count_down&lt;/code&gt;) can't be verified by its return value alone, so I switched to the mindset of &lt;strong&gt;checking correctness by looking at the output&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Injecting an exception with &lt;code&gt;patch('time.sleep', side_effect=KeyboardInterrupt)&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If I swap out &lt;code&gt;time.sleep&lt;/code&gt; with &lt;code&gt;patch&lt;/code&gt; and set &lt;code&gt;side_effect&lt;/code&gt; to an exception, that exception is raised the moment &lt;code&gt;sleep&lt;/code&gt; is called. That let me &lt;strong&gt;reproduce "the user pressed Ctrl+C" from the test&lt;/strong&gt;. It feels close to writing &lt;code&gt;when(...).thenThrow(...)&lt;/code&gt; with Mockito in Java.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The normal-path &lt;code&gt;count_down(1)&lt;/code&gt; actually waits one second&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only the normal-path test leaves &lt;code&gt;sleep&lt;/code&gt; un-mocked and really waits one second. One second is within tolerance, so I deliberately ran it as-is here.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;A spot I got stuck on&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I tripped up once on "where to apply the mock" (detailed below in "Where I Got Stuck"). At first I tried to mock the input, but &lt;code&gt;input&lt;/code&gt; isn't called inside &lt;code&gt;count_down&lt;/code&gt;, so I was applying it to the wrong place.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What I Asked AI For
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;What AI helped with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Code review&lt;/td&gt;
&lt;td&gt;Feedback on strengths and improvements after I finished implementing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intent of &lt;code&gt;finally&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Confirming that "putting the shared message in &lt;code&gt;finally&lt;/code&gt;" was the intended design (confirmed it was intentional)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verifying the display trick&lt;/td&gt;
&lt;td&gt;Confirming the role split of &lt;code&gt;\r&lt;/code&gt; plus &lt;code&gt;end=""&lt;/code&gt; plus &lt;code&gt;flush=True&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LEARNING_LOG&lt;/td&gt;
&lt;td&gt;Tidying up the learning log&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note: the idea of &lt;strong&gt;overwriting a line with &lt;code&gt;\r&lt;/code&gt;, and identifying where to apply &lt;code&gt;patch&lt;/code&gt;&lt;/strong&gt; (next section) were things I thought through and debugged myself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Mocked it, but no exception fires — the patch target was wrong
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; When writing the &lt;code&gt;KeyboardInterrupt&lt;/code&gt; test, I first thought "if I swap out the input I can raise the exception," and applied &lt;code&gt;patch('builtins.input', ...)&lt;/code&gt;. But no exception fired at all, and the test didn't behave as intended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Looking closely, &lt;code&gt;input&lt;/code&gt; is &lt;strong&gt;never called&lt;/strong&gt; inside the &lt;code&gt;count_down&lt;/code&gt; function. The one using &lt;code&gt;input&lt;/code&gt; is &lt;code&gt;main.py&lt;/code&gt;, and what actually gets called and waits inside &lt;code&gt;count_down&lt;/code&gt; is &lt;code&gt;time.sleep&lt;/code&gt;. In other words, "where I wanted to inject the exception" and "where I applied the mock" were out of sync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; I changed the mock target to &lt;code&gt;time.sleep&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# NG: input is not called inside count_down, so nothing happens when applied here
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;builtins.input&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;side_effect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;KeyboardInterrupt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="c1"&gt;# OK: apply it to time.sleep, which is actually called inside count_down
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;time.sleep&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;side_effect&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;KeyboardInterrupt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; &lt;strong&gt;A mock has no effect unless you apply it where the call actually happens.&lt;/strong&gt; You decide the target by looking not at "what I want to swap out" but at "what the function under test actually calls." I got stuck on "is the path correct?" with &lt;code&gt;__init__.py&lt;/code&gt; import resolution last time, and a mock is the same in that "is it pointing at the right target?" is the crux.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;"An error occurred: Exception"&lt;/code&gt; makes the assertion fail — the empty-message trap
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; In the generic &lt;code&gt;Exception&lt;/code&gt; test, I first wrote the assertion expecting the output &lt;code&gt;"An error occurred: Exception"&lt;/code&gt;, and it failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The production code embeds the exception with &lt;code&gt;print(f"An error occurred: {e}")&lt;/code&gt;. Here &lt;code&gt;side_effect=Exception&lt;/code&gt; throws an &lt;strong&gt;&lt;code&gt;Exception()&lt;/code&gt; with no arguments&lt;/strong&gt;, so the &lt;code&gt;str(e)&lt;/code&gt; going into &lt;code&gt;{e}&lt;/code&gt; becomes an &lt;strong&gt;empty string&lt;/strong&gt;. The actual output is therefore &lt;code&gt;"An error occurred: "&lt;/code&gt; (nothing after the colon), which doesn't match &lt;code&gt;"An error occurred: Exception"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; I relaxed the assertion to only "does it contain the first part?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# NG: expected the exception message (after the colon) to match too
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;An error occurred: Exception&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;capsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readouterr&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;

&lt;span class="c1"&gt;# OK: verify only the first part that always appears
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;An error occurred&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;capsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readouterr&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; I only noticed that &lt;code&gt;str(Exception())&lt;/code&gt; becomes an empty string by reading the error log. The lesson is that &lt;strong&gt;narrowing a test assertion to "the minimal fact that always holds"&lt;/strong&gt; makes it harder to break and clearer in intent. Following last time's "crush probabilistic failures with a large enough number of trials," my sense for "how to keep tests stable" is slowly growing.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  Python syntax and standard library
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Key takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;time.sleep(s)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pauses the process for the given seconds (counterpart of Java's &lt;code&gt;Thread.sleep(ms)&lt;/code&gt;, in seconds)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;range(n, 0, -1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A reverse loop with &lt;code&gt;step=-1&lt;/code&gt;; useful for a countdown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\r&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns the cursor to the start of the line and overwrites it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;print(..., end="", flush=True)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Suppresses the newline and reflects output immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;try / except / finally&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same structure as Java; &lt;code&gt;finally&lt;/code&gt; always runs regardless of the ending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KeyboardInterrupt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You can catch Ctrl+C as an exception&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Testing knowledge
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Key takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;capsys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Captures &lt;code&gt;print&lt;/code&gt; output as a string for verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;patch('time.sleep', side_effect=Exc)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Swaps a function to raise an exception on purpose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mock target&lt;/td&gt;
&lt;td&gt;Has no effect unless applied to "what the test target actually calls"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relaxing assertions&lt;/td&gt;
&lt;td&gt;Narrowing to a minimal fact that always holds keeps tests robust&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Design and debugging thinking
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;What to put in &lt;code&gt;finally&lt;/code&gt; is decided by working backward from "what message do I want on every ending?"&lt;/li&gt;
&lt;li&gt;The look of the display (overwriting with &lt;code&gt;\r&lt;/code&gt;) is something to treat as part of UX design.&lt;/li&gt;
&lt;li&gt;When an error appears, read the &lt;strong&gt;difference&lt;/strong&gt; between "the actual output" and "the expected output." The pattern "if the &lt;code&gt;patch&lt;/code&gt; target is wrong, no exception fires" has become a familiar way to isolate causes.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Reflection
&lt;/h2&gt;

&lt;p&gt;As before, I built this by writing the code myself and having AI review it, and I completed implementation, debugging, and testing on my own. As my third Python project, three things stood out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I stepped into designing a "process that stops partway" for the first time:&lt;/strong&gt; So far the processes always ran to completion, but this time I designed a way to gracefully catch an interruption via Ctrl+C — an ending that is neither the normal path nor an error — with &lt;code&gt;try / except / finally&lt;/code&gt;. It was a nice bonus that my knowledge of Java's &lt;code&gt;finally&lt;/code&gt; carried straight over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A lesson from the mock target:&lt;/strong&gt; I learned the mock principle "decide the target by what the target actually calls, not by what you want to swap out" by actually getting stuck. As with last time's import resolution, I re-confirmed from a different angle how important it is to "point at the right path / target."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A small win in display control:&lt;/strong&gt; The moment the number rewrote in place with &lt;code&gt;\r&lt;/code&gt; was a quiet but real sense of "now it feels like a timer." Going one step beyond "it just works" to caring about how it looks feels continuous with my front-end learning too.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This was my record of building a countdown timer CLI in Python, centered on &lt;strong&gt;time, finally, and mock&lt;/strong&gt; — my third article in the Python series.&lt;/p&gt;

&lt;p&gt;Continuous progress from last time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Learned to write a "process that advances over time" with &lt;code&gt;time.sleep&lt;/code&gt; and &lt;code&gt;range(n, 0, -1)&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Picked up display control that overwrites the same line with &lt;code&gt;\r&lt;/code&gt; plus &lt;code&gt;end=""&lt;/code&gt; plus &lt;code&gt;flush=True&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanly designed a "process that stops partway" with &lt;code&gt;try / except / finally&lt;/code&gt;&lt;/strong&gt; (Java knowledge paid off)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wrote tests that swap out &lt;code&gt;time.sleep&lt;/code&gt; with &lt;code&gt;patch&lt;/code&gt; to raise exceptions on purpose&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Learned the principle "apply a mock where the call happens" from actually getting stuck&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Next, I'll finally move into &lt;strong&gt;persistence to a file&lt;/strong&gt; (a diary app). I'll graduate from the "data disappears when you exit" simplification I've kept up to now in the next stage.&lt;/p&gt;

&lt;p&gt;The full learning log is in the repository:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/uya0526-design/countdown_timer_py/blob/main/LEARNING_LOG.md" rel="noopener noreferrer"&gt;countdown_timer_py / LEARNING_LOG.md&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article is part of my public learning journey using AI tools (Claude Pro / Cursor Pro). The thinking and all code are mine; I collaborate with AI on the writing (structure, drafting, translation) and verify every line before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>pytest</category>
      <category>beginners</category>
      <category>testing</category>
    </item>
    <item>
      <title>Building a Password Generator CLI in Python — string, random, any, and Testing Randomness</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Sat, 20 Jun 2026 11:24:56 +0000</pubDate>
      <link>https://dev.to/uya0526design/building-a-password-generator-cli-in-python-string-random-any-and-testing-randomness-5128</link>
      <guid>https://dev.to/uya0526design/building-a-password-generator-cli-in-python-string-random-any-and-testing-randomness-5128</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is my ninth article as a Java engineer learning TypeScript and Python from scratch.&lt;/p&gt;

&lt;p&gt;Last time I moved into the Python series, and the first project was a &lt;strong&gt;weight tracker CLI&lt;/strong&gt;. Centered on type hints, pure functions, and pytest, it confirmed that "separate logic from I/O" still works in Python.&lt;/p&gt;

&lt;p&gt;For the second Python project, this time it's a &lt;strong&gt;password generator CLI&lt;/strong&gt;. You specify the length and whether to include digits and symbols, and it generates a random password. Where the last one was about "separating calculation logic," this one centers on &lt;strong&gt;string manipulation and randomness&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What I focused on this time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Building a character pool by concatenating &lt;code&gt;string&lt;/code&gt; constants (&lt;code&gt;ascii_letters&lt;/code&gt;, &lt;code&gt;digits&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Picking one character at a time with &lt;code&gt;random.choice()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;any()&lt;/code&gt;&lt;/strong&gt; to check whether an iterable contains an element that meets a condition (close to Java's &lt;code&gt;anyMatch&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extracting validation logic into independent functions&lt;/strong&gt; (reused from both main and tests)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A testing strategy for randomness&lt;/strong&gt; (how to avoid probabilistic failures)&lt;/li&gt;
&lt;li&gt;Package recognition with &lt;code&gt;__init__.py&lt;/code&gt;, and resolving pytest's imports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As always, I write honestly about where I got stuck, what I thought through, and what I asked AI for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Where this sits in the series&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the second article in my Python series. It's also a chance to check whether the habits I learned last time — "separation of concerns" and "write tests" — hold up with a subject that involves randomness.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  My Learning Style (AI Transparency)
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Learning companions &amp;amp; how this article is written&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;Claude Pro&lt;/strong&gt; (design discussions, Q&amp;amp;A, and article drafting) and &lt;strong&gt;Cursor Pro&lt;/strong&gt; (coding support).&lt;/p&gt;

&lt;p&gt;Division of roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tech selection, design, implementation, and code verification&lt;/strong&gt; → me&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Article structure, outline, draft prose, and translation&lt;/strong&gt; → in collaboration with Claude&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All content is checked and revised by me before publishing&lt;/strong&gt; → me&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;For the code, I set myself these rules: I write the code myself (I never ask AI to write code for me), I use AI for hints, spec clarification, and bug spotting, and I make sure I understand &lt;em&gt;why&lt;/em&gt; before moving on. In this article I clearly separate "what I implemented myself" from "what I asked AI for." My line is "&lt;strong&gt;the thinking and decisions are mine, the wording is AI-assisted, and I verify the final content myself.&lt;/strong&gt;" This isn't an apology — just stating the facts.&lt;/p&gt;




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

&lt;p&gt;Enter a menu number, and the CLI generates a password or lists generated ones. You can interactively specify the length and whether to include digits and special characters, and generated passwords are kept as history during the session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;---------------------------------
1. Generate a password
2. List generated passwords
3. Exit
---------------------------------
Enter your choice: 1
Enter the length of the password:
20
Should I include digits? (y/n):
y
Should I include special characters? (y/n):
y
Generated password: BRBK3%n%D2Tm1y4-DG1[
Password generated successfully.
---------------------------------
1. Generate a password
2. List generated passwords
3. Exit
---------------------------------
Enter your choice: 2
1. BRBK3%n%D2Tm1y4-DG1[
---------------------------------
Enter your choice: 3
Exiting the program...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Data is held &lt;strong&gt;in memory&lt;/strong&gt; and resets when you exit (a deliberate simplification for local learning). I'm saving persistence for a later stage.&lt;/p&gt;

&lt;p&gt;📦 Repository: &lt;a href="https://github.com/uya0526-design/password_generator_py" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/password_generator_py&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  File Structure and Tech Stack
&lt;/h2&gt;

&lt;p&gt;Last time it was a single file (&lt;code&gt;main.py&lt;/code&gt;), but this time I &lt;strong&gt;split the logic (&lt;code&gt;password.py&lt;/code&gt;) from the entry point (&lt;code&gt;main.py&lt;/code&gt;).&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;password_generator_py/
├── src/
│   ├── __init__.py
│   ├── main.py          # entry point
│   └── password.py      # password generation logic
├── tests/
│   ├── __init__.py
│   └── test_password.py # unit tests
├── requirements.txt     # dependencies
├── LEARNING_LOG.md      # learning log
└── README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tech stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python&lt;/strong&gt; 3.12&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pytest&lt;/strong&gt; 9.0.3&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I split the logic (&lt;code&gt;password.py&lt;/code&gt;) from the I/O (&lt;code&gt;main.py&lt;/code&gt;) at the file level because I wanted to take last time's "separation of concerns" one step further. In Java terms, it feels close to separating a &lt;code&gt;Service&lt;/code&gt; class that holds the logic from the class that holds the &lt;code&gt;main&lt;/code&gt; entry point.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Implemented Myself
&lt;/h2&gt;

&lt;h3&gt;
  
  
  password.py — Building a character pool and picking randomly
&lt;/h3&gt;

&lt;p&gt;This is the heart of password generation. Starting from &lt;code&gt;string.ascii_letters&lt;/code&gt;, I &lt;strong&gt;concatenate digits and symbols as strings&lt;/strong&gt; depending on the flags, then pick one character at a time from that pool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;

&lt;span class="n"&gt;special_chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!@#$%^&amp;amp;*()_+-=[]{}|;:,.&amp;lt;&amp;gt;?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;generated_passwords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;use_digits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;use_special_chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A password is generated based on the result selected from the menu&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;random_string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ascii_letters&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;use_digits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;random_string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random_string&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;use_special_chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;random_string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random_string&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;special_chars&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random_string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;generated_passwords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three points here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The &lt;code&gt;string&lt;/code&gt; module's character constants&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;string.ascii_letters&lt;/code&gt; (upper- and lowercase letters) and &lt;code&gt;string.digits&lt;/code&gt; (&lt;code&gt;0&lt;/code&gt; to &lt;code&gt;9&lt;/code&gt;) are provided as strings out of the box. Where in Java I'd loop to build them or write out the constants by hand, here the standard library just hands them over. I concatenate only the character types whose flags are set, with &lt;code&gt;+&lt;/code&gt;, to build the "pool of allowed characters."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;for _ in range(length)&lt;/code&gt; without a loop variable&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a case of "I just want to repeat &lt;code&gt;length&lt;/code&gt; times and don't use the loop variable's value." In Python, the convention is to put &lt;code&gt;_&lt;/code&gt;. The same situation as a Java &lt;code&gt;for (int i = 0; i &amp;lt; length; i++)&lt;/code&gt; where &lt;code&gt;i&lt;/code&gt; is unused, but here I can make "unused" explicit with &lt;code&gt;_&lt;/code&gt;, which felt fresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Picking one element with &lt;code&gt;random.choice()&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;random.choice(seq)&lt;/code&gt; returns one random element from a sequence. What I used to write as &lt;code&gt;list.get(random.nextInt(list.size()))&lt;/code&gt; in Java fits into a single function.&lt;/p&gt;

&lt;h3&gt;
  
  
  password.py — Extracting validation logic into independent functions
&lt;/h3&gt;

&lt;p&gt;I prepared functions to confirm "does the generated password really contain the specified character types?", &lt;strong&gt;made independent of the main logic.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_digits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Check if the password contains digits&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;char&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;char&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_special_chars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Check if the password contains special characters&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;char&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;special_chars&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;char&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where I used &lt;strong&gt;&lt;code&gt;any()&lt;/code&gt;&lt;/strong&gt; for the first time. &lt;code&gt;any()&lt;/code&gt; returns &lt;code&gt;True&lt;/code&gt; if the iterable contains at least one &lt;code&gt;True&lt;/code&gt;. So &lt;code&gt;any(char.isdigit() for char in password)&lt;/code&gt; expresses "is there at least one digit in the password?" in a single line.&lt;/p&gt;

&lt;p&gt;In Java's Stream it's close to &lt;code&gt;password.chars().anyMatch(Character::isDigit)&lt;/code&gt;: the classic "loop and &lt;code&gt;return true&lt;/code&gt; partway through" becomes a declarative one-liner, which felt great.&lt;/p&gt;

&lt;p&gt;And the important part is that I &lt;strong&gt;extracted these validation functions as independent functions.&lt;/strong&gt; Last time I learned to split calculation logic into pure functions; this time, by splitting the validation logic, I could reuse the same functions from both the main process (the warning display) and the test code. That pays off in the next section's tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  password.py — Listing, and the start argument of enumerate
&lt;/h3&gt;

&lt;p&gt;For listing the history, I improved on the &lt;code&gt;enumerate&lt;/code&gt; I'd used last time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_generated_passwords&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;List the generated passwords&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;generated_passwords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No passwords have been generated yet.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generated_passwords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Last time I wrote &lt;code&gt;index + 1&lt;/code&gt; to make the display start at 1, but this time I learned you can &lt;strong&gt;pass a start number as the second argument&lt;/strong&gt;, like &lt;code&gt;enumerate(generated_passwords, 1)&lt;/code&gt;. A small improvement, but a spot where I felt "I wrote this a little more cleanly than last time."&lt;/p&gt;

&lt;p&gt;For the empty check I also wrote &lt;code&gt;if not generated_passwords:&lt;/code&gt; instead of &lt;code&gt;len(...) == 0&lt;/code&gt;. In Python an empty list evaluates as false, so this reads more naturally — something I was taught.&lt;/p&gt;

&lt;h3&gt;
  
  
  main.py — The menu loop and input validation
&lt;/h3&gt;

&lt;p&gt;The entry point &lt;code&gt;main.py&lt;/code&gt; runs the menu with &lt;code&gt;while True&lt;/code&gt; and guards numeric input with &lt;code&gt;try / except&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Enter your choice: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Enter the length of the password: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid input. Please enter a valid number.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try / except ValueError&lt;/code&gt; I learned in the weight tracker CLI carried straight over. Input that &lt;code&gt;int()&lt;/code&gt; can't convert raises &lt;code&gt;ValueError&lt;/code&gt; (close to Java's &lt;code&gt;NumberFormatException&lt;/code&gt;), so I catch it and &lt;code&gt;continue&lt;/code&gt; back to the top of the menu.&lt;/p&gt;

&lt;h3&gt;
  
  
  main.py — Showing the warning just once with remind_flag
&lt;/h3&gt;

&lt;p&gt;Something I tweaked this time is the warning for "when the specified character type didn't happen to be included." Since we pick with &lt;code&gt;random&lt;/code&gt;, at around 20 characters it's possible to draw no digits at all. So after generating, I check the contents with the validation functions and warn if something is missing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;remind_flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;include_digits&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;get_digits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Digits are not included in the password.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;remind_flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;include_special_chars&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;get_special_chars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Special characters are not included in the password.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;remind_flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remind_flag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;If you mind, please generate a password again.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I keep a single &lt;code&gt;remind_flag&lt;/code&gt; so that "if either digits or symbols are missing, show 'generate again if you mind' just once at the end." I'm reusing the &lt;code&gt;get_digits&lt;/code&gt; / &lt;code&gt;get_special_chars&lt;/code&gt; I extracted earlier right here.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;A note for next time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In LEARNING_LOG I noted that "instead of a flag variable, you could collect the warning messages into a list and show them all at the end." &lt;code&gt;remind_flag&lt;/code&gt; was enough this time, but if warnings grow, the list approach would be easier to extend.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  tests/test_password.py — How to test randomness
&lt;/h3&gt;

&lt;p&gt;The part that took the most thought this time was testing. A function that uses &lt;code&gt;random&lt;/code&gt; &lt;strong&gt;produces different results every run&lt;/strong&gt;, so a naive test becomes one that "happens to pass / happens to fail."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.password&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_digits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_special_chars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generate_password&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_get_digits&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;get_digits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;get_digits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_generate_password&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;generate_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;generate_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_generate_password_with_digits&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Check for randomness by checking 1000 length passwords&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;get_digits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I worked out is &lt;code&gt;test_generate_password_with_digits&lt;/code&gt;. Even when I specify "include digits," a short password might &lt;strong&gt;by chance contain no digits at all&lt;/strong&gt;. That makes the test unstable.&lt;/p&gt;

&lt;p&gt;So I decided to &lt;strong&gt;generate a length-1000 password and verify that.&lt;/strong&gt; With 1000 characters, the probability of drawing zero from the digit pool is effectively near zero. Reaching the idea "if you can't remove randomness, crush the probabilistic failure with a large enough number of trials" on my own was the win this time.&lt;/p&gt;

&lt;p&gt;For the verification, I could reuse the &lt;code&gt;get_digits&lt;/code&gt; I'd extracted earlier. &lt;strong&gt;Because I'd split the validation logic, the production code and the test could share the same check.&lt;/strong&gt; The &lt;code&gt;isinstance(..., str)&lt;/code&gt; test to confirm the return type also added one more tool to my belt, following last time's &lt;code&gt;pytest.raises&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Asked AI For
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;What AI helped with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Breaking down the steps&lt;/td&gt;
&lt;td&gt;Suggesting the order: password.py first, then main.py, then test_password.py&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The &lt;code&gt;string&lt;/code&gt; module&lt;/td&gt;
&lt;td&gt;Telling me about character constants like &lt;code&gt;ascii_lowercase&lt;/code&gt;, &lt;code&gt;ascii_uppercase&lt;/code&gt;, &lt;code&gt;digits&lt;/code&gt;, &lt;code&gt;punctuation&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random selection options&lt;/td&gt;
&lt;td&gt;Hinting at the difference between &lt;code&gt;random.choices()&lt;/code&gt; and &lt;code&gt;random.choice()&lt;/code&gt; (I chose &lt;code&gt;choice()&lt;/code&gt; myself)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type hints&lt;/td&gt;
&lt;td&gt;Suggesting I add type hints to function signatures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Package structure&lt;/td&gt;
&lt;td&gt;The role of &lt;code&gt;__init__.py&lt;/code&gt; and how pytest resolves imports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;README&lt;/td&gt;
&lt;td&gt;Tidying it up in both Japanese and English&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note: the idea to &lt;strong&gt;test randomness with a long string&lt;/strong&gt; was mine, and I decided the validation-function split myself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. AttributeError: 'str' object has no attribute 'get_digits'
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; When calling the validation, I wrote &lt;code&gt;password.get_digits(password)&lt;/code&gt; and got an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; &lt;code&gt;get_digits&lt;/code&gt; is a &lt;strong&gt;module-level function&lt;/strong&gt; defined in &lt;code&gt;password.py&lt;/code&gt;, but I was calling it as if it were a &lt;strong&gt;method&lt;/strong&gt; of &lt;code&gt;password&lt;/code&gt; (a string variable). A string has no method called &lt;code&gt;get_digits&lt;/code&gt;, hence &lt;code&gt;AttributeError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; I changed it to a plain function call, &lt;code&gt;get_digits(password)&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# NG: trying to call a method on the string password
&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_digits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# OK: pass password to the module's function
&lt;/span&gt;&lt;span class="nf"&gt;get_digits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; "&lt;code&gt;a.b()&lt;/code&gt; (method call)" and "&lt;code&gt;b(a)&lt;/code&gt; (pass to a function)" are different things. In Java basically everything is a class method, so I realized my hands aren't yet used to the Python sense of &lt;strong&gt;being able to put functions at the module top level.&lt;/strong&gt; It also helped that reading the error message spelled out the cause clearly.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. ModuleNotFoundError — pytest can't find the module
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; When I ran &lt;code&gt;pytest&lt;/code&gt;, the test file's import raised &lt;code&gt;ModuleNotFoundError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause and trial-and-error:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First &lt;code&gt;from password import ...&lt;/code&gt; gave &lt;code&gt;No module named 'password'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Changing to &lt;code&gt;from src.password import ...&lt;/code&gt; then gave &lt;code&gt;No module named 'src'&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither &lt;code&gt;src/&lt;/code&gt; nor &lt;code&gt;tests/&lt;/code&gt; was recognized by Python as a package; they were "just folders."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; I placed &lt;code&gt;src/__init__.py&lt;/code&gt; and &lt;code&gt;tests/__init__.py&lt;/code&gt; (both empty files) so the folders are recognized as packages, which resolved it. I'd solved a similar problem with &lt;code&gt;tests/__init__.py&lt;/code&gt; in the weight tracker CLI, so the instinct "when imports don't resolve, first suspect package recognition" was starting to kick in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Folder structure is part of "the spec for making things run." The way the presence of &lt;code&gt;__init__.py&lt;/code&gt; changes import resolution felt a little like the correspondence between Java's package declarations and directory structure.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  Python knowledge
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Key takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;string&lt;/code&gt; module&lt;/td&gt;
&lt;td&gt;Character constants like &lt;code&gt;ascii_letters&lt;/code&gt;, &lt;code&gt;digits&lt;/code&gt;, &lt;code&gt;punctuation&lt;/code&gt; can be concatenated as strings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;random.choice(seq)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Picks one random element from a sequence (close to Java's &lt;code&gt;list.get(random.nextInt(...))&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;any()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;True&lt;/code&gt; if at least one element of an iterable meets a condition (close to Java Stream's &lt;code&gt;anyMatch&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;for _ in range(n)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;_&lt;/code&gt; to make "unused" explicit when you don't need the loop variable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enumerate(seq, 1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The second argument sets the start number (more natural than &lt;code&gt;index + 1&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Empty check&lt;/td&gt;
&lt;td&gt;An empty list is false; &lt;code&gt;if not list:&lt;/code&gt; expresses "if empty"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;__init__.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Makes a folder recognized as a Python package&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Testing knowledge
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Key takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Testing randomness&lt;/td&gt;
&lt;td&gt;Make the number of trials (characters) large enough to crush probabilistic failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;isinstance(value, type)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Useful for tests that confirm the return type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sharing validation functions&lt;/td&gt;
&lt;td&gt;The production code and the test can reuse the same check function&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Design thinking
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Splitting validation logic into a separate function&lt;/strong&gt; lets both the main logic (the warning display) and the test code reuse it. This became an applied version of separation of concerns, following last time's "splitting calculation logic."&lt;/li&gt;
&lt;li&gt;Instead of a flag variable (&lt;code&gt;remind_flag&lt;/code&gt;), you could &lt;strong&gt;collect the warning messages into a list and show them all at the end&lt;/strong&gt; (an option for next time if I extend this).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Reflection
&lt;/h2&gt;

&lt;p&gt;I built this the same way as before: write the code myself and have AI review it. As my second Python project, three things stood out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separation of concerns went one level deeper:&lt;/strong&gt; Last time it was "extract calculation logic as a pure function"; this time I went as far as "extract validation logic and share it between production and tests." The feeling of the same principle widening its range of application was a satisfying sense of continuous growth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I learned how to deal with randomness and testing:&lt;/strong&gt; "How do you verify a process whose result changes every time?" was a worry that the deterministic processes so far never had. Reaching the idea "make the number of trials large to crush probabilistic failures" on my own was the biggest gain this time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pleasure of writing declaratively with &lt;code&gt;any()&lt;/code&gt;:&lt;/strong&gt; A process that loops and &lt;code&gt;return true&lt;/code&gt;s partway through becomes a single &lt;code&gt;any(...)&lt;/code&gt; line. The same "from procedural to declarative" feeling I had when learning Java's Stream came back in Python.&lt;/p&gt;

&lt;p&gt;The ex-Java habit (confusing a method call with a function call) did surface, but I corrected it along with the reason, from the error message.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This was my record of building a password generator CLI in Python, centered on &lt;strong&gt;string, random, and any&lt;/strong&gt; — my second article in the Python series.&lt;/p&gt;

&lt;p&gt;Continuous progress from last time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Picked up the basics of building strings with &lt;code&gt;string&lt;/code&gt; and &lt;code&gt;random.choice()&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Learned to write "is at least one condition met?" declaratively with &lt;code&gt;any()&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Practiced a design that extracts validation logic and shares it between production code and tests&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tested randomness with the idea of "crushing probabilistic failures by making the number of trials large"&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resolved package recognition with &lt;code&gt;__init__.py&lt;/code&gt; on my own, drawing on last time's experience&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Next, I want to move into &lt;strong&gt;persistence&lt;/strong&gt; — saving generated passwords to a file so they can be listed across sessions — and gradually graduate from this project's "data disappears when you exit" simplification.&lt;/p&gt;

&lt;p&gt;The full learning log is in the repository:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/uya0526-design/password_generator_py/blob/main/LEARNING_LOG.md" rel="noopener noreferrer"&gt;password_generator_py / LEARNING_LOG.md&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article is part of my public learning journey using AI tools (Claude Pro / Cursor Pro). The thinking and all code are mine; I collaborate with AI on the writing (structure, drafting, translation) and verify every line before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>pytest</category>
      <category>beginners</category>
      <category>testing</category>
    </item>
    <item>
      <title>Not 'Did You Use AI' but 'Are You the One Driving' — Reflections on Building a Real Product Through AI Collaboration</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Thu, 18 Jun 2026 12:49:36 +0000</pubDate>
      <link>https://dev.to/uya0526design/not-did-you-use-ai-but-are-you-the-one-driving-reflections-on-building-a-real-product-through-464m</link>
      <guid>https://dev.to/uya0526design/not-did-you-use-ai-but-are-you-the-one-driving-reflections-on-building-a-real-product-through-464m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Originally published in Japanese on Zenn.&lt;/strong&gt; This is the English version.&lt;br&gt;
Canonical: &lt;a href="https://zenn.dev/uya0526_design/articles/satellite4_ai-collaboration" rel="noopener noreferrer"&gt;https://zenn.dev/uya0526_design/articles/satellite4_ai-collaboration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📚 This is &lt;strong&gt;satellite article #4 (the finale)&lt;/strong&gt; in my "Read-Aloud Speed Meter dev log" series. For the whole picture, see the &lt;a href="https://dev.to/uya0526design/measuring-japanese-read-aloud-speed-with-amivoice-timestamps-a-coaching-app-that-doesnt-stop-at-18e3"&gt;main article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where This Sits
&lt;/h2&gt;

&lt;p&gt;The read-aloud speed meter was &lt;strong&gt;the first project where I adopted "AI-collaborative development" as an explicit mode.&lt;/strong&gt; Until then, my learning style was "I write all the code myself; AI is a reviewer." With a contest deadline looming, I stepped one notch further.&lt;/p&gt;

&lt;p&gt;This article isn't a technical deep dive — it's a reflection on &lt;strong&gt;the development style itself.&lt;/strong&gt; Three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The reframe from "did you use AI" to &lt;strong&gt;"are you the one driving"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;stumbles I actually hit&lt;/strong&gt; in AI collaboration, and the patterns I pulled out of them&lt;/li&gt;
&lt;li&gt;Why &lt;strong&gt;rejecting&lt;/strong&gt; an AI suggestion was the single most important thing&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 This is a record of an ex-Java SE engineer learning TypeScript and Python in public. It's less a technical article and more a reflective, prose-y piece on the development process.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  I Switched Styles
&lt;/h2&gt;

&lt;p&gt;My learning articles have always run on a rule: &lt;strong&gt;I write the code myself; I use AI for hints, spec clarification, and bug spotting.&lt;/strong&gt; Typing every word myself had learning value.&lt;/p&gt;

&lt;p&gt;This time there was a contest deadline, and a huge amount to cover. So I switched into a collaborative mode: &lt;strong&gt;AI demonstrates boilerplate (recording, fetch, API Route skeletons), and I handle the conceptual core and the design decisions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The awkward part was &lt;strong&gt;how to disclose that in the article.&lt;/strong&gt; I'd publicly stated my AI use for code before, but this time I collaborated with AI on &lt;strong&gt;the article's outline, structure, draft prose, and even translation.&lt;/strong&gt; In a contest with money (a prize) on the line, blurring that didn't feel honest.&lt;/p&gt;

&lt;p&gt;At first I framed it as "using AI is no different from accepting an IDE's autocomplete." But that was inaccurate.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Autocomplete ≈ word/line-level completion&lt;/li&gt;
&lt;li&gt;This time ≈ delegating outline, structure, drafting, and translation → closer to &lt;strong&gt;co-building the article with an editor&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since the degree of involvement was far greater than autocomplete, blurring it as "I used AI a little" was wrong. I reconsidered: &lt;strong&gt;writing concretely what I delegated to AI and what I did myself&lt;/strong&gt; is both more honest and more self-protective.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Reframe: Not "Did You Use AI" but "Are You the One Driving"
&lt;/h2&gt;

&lt;p&gt;The most useful lens for thinking about disclosure was this.&lt;/p&gt;

&lt;p&gt;The question isn't "&lt;strong&gt;did you use AI&lt;/strong&gt;" — it's "&lt;strong&gt;are you the one driving.&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;This comes from Zenn's community guidelines emphasizing "a place where people drive the dissemination of information," and listing two conditions for "the person is driving":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The author verifies the content before publishing&lt;/li&gt;
&lt;li&gt;The author embeds their own experience and insight into the content&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Against this standard, disclosure isn't a &lt;strong&gt;"confession"&lt;/strong&gt; — it's &lt;strong&gt;"presenting evidence that I meet the bar."&lt;/strong&gt; This project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;made its own decisions on tech selection (consolidating on Next.js), the evaluation-algorithm design, and the architecture (BFF)&lt;/li&gt;
&lt;li&gt;verified the code's behavior with Vitest, starting with &lt;code&gt;calculateMetrics&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;holds &lt;strong&gt;first-hand information&lt;/strong&gt; like audio-format compatibility, the divide-by-zero guard, and the hunt for threshold bases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In that sense, I'm on the side that meets the "driving" conditions. So disclosure isn't scary — it's actually a presentation of strengths.&lt;/p&gt;

&lt;p&gt;I summarized the axis of disclosure like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The thinking is mine, the wording is AI-assisted, and I verify all of it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tech selection, evaluation-algorithm design, architecture decisions, code verification&lt;/td&gt;
&lt;td&gt;Author&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Article outline, structure, draft prose, translation&lt;/td&gt;
&lt;td&gt;In collaboration with AI (Claude)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reviewing and revising all content before publishing&lt;/td&gt;
&lt;td&gt;Author&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I decided the tone too: &lt;strong&gt;no apologetic register — state the facts plainly.&lt;/strong&gt; Writing apologetically signals "the author thinks AI use is a problem," which invites the very criticism I want to avoid. Laying out facts calmly conveys "this is just a normal professional workflow."&lt;/p&gt;




&lt;h2&gt;
  
  
  A Collection of Stumbles — Where Collaboration Got Stuck
&lt;/h2&gt;

&lt;p&gt;AI collaboration is fast, but it isn't unconditionally correct. Here are representative cases I hit, isolated, and fixed myself. The technical details are in &lt;a href="https://dev.to/uya0526design/calling-amivoices-synchronous-http-api-through-a-nextjs-bff-auth-multipart-order-and-the-webm-21o5"&gt;satellite #1 (AmiVoice)&lt;/a&gt; and &lt;a href="https://dev.to/uya0526design/dont-let-claude-haiku-do-the-math-a-two-stage-read-aloud-coach-design-and-the-prompt-swamp-2ihc"&gt;satellite #2 (Haiku)&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ① I got AmiVoice's auth method wrong
&lt;/h3&gt;

&lt;p&gt;I assumed REST API auth meant an &lt;code&gt;Authorization&lt;/code&gt; header. AmiVoice's synchronous HTTP puts the &lt;strong&gt;API key in the multipart &lt;code&gt;u&lt;/code&gt; field.&lt;/strong&gt; I read the official manual myself, hit it with curl, and finally corrected it.&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;Lesson:&lt;/strong&gt; For external APIs, take a &lt;strong&gt;two-stage check — official spec + actual testing (curl)&lt;/strong&gt; — not just AI's explanation.&lt;/p&gt;

&lt;h3&gt;
  
  
  ② The mapper returned empty (&lt;code&gt;result&lt;/code&gt; vs &lt;code&gt;results&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;In the mapper that reshapes raw JSON, the first version referenced &lt;code&gt;result.tokens&lt;/code&gt; (singular) and got nothing. The correct path was &lt;code&gt;results[0].tokens&lt;/code&gt; (a plural array).&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;Lesson:&lt;/strong&gt; This was &lt;strong&gt;preventable if I'd looked at the raw JSON with curl first.&lt;/strong&gt; See the real data with your own eyes before implementing.&lt;/p&gt;

&lt;h3&gt;
  
  
  ③ I rejected &lt;code&gt;0&lt;/code&gt; as falsy
&lt;/h3&gt;

&lt;p&gt;In an input check I wrote &lt;code&gt;if (!value)&lt;/code&gt;, which rejected a stagnation rate of &lt;code&gt;0&lt;/code&gt; (which is actually a &lt;em&gt;good&lt;/em&gt; result). Since &lt;code&gt;0&lt;/code&gt; is falsy, an existence check needs &lt;code&gt;?? ""&lt;/code&gt; or &lt;code&gt;=== undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;Lesson:&lt;/strong&gt; "no value" and "value is &lt;code&gt;0&lt;/code&gt;" are different things. This was the same as distinguishing Java's &lt;code&gt;Optional&lt;/code&gt;/null check from an empty/0 check.&lt;/p&gt;

&lt;h3&gt;
  
  
  ④ The prompt cache wasn't working
&lt;/h3&gt;

&lt;p&gt;I thought I'd cheapened a static prompt with caching, but it didn't reach Claude Haiku's &lt;strong&gt;4,096-token minimum&lt;/strong&gt;, so it wasn't working at all. I only noticed by looking at &lt;code&gt;usage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;Lesson:&lt;/strong&gt; "The official feature exists" and "it helps in my use case" are different. Check the preconditions too.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⑤ Haiku mentioned punctuation that wasn't in the input
&lt;/h3&gt;

&lt;p&gt;Even for short reads with no punctuation, Haiku tacked on "take a breath at the punctuation." Fixing the prompt still doesn't prevent it 100%.&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;Lesson:&lt;/strong&gt; &lt;strong&gt;Written in the prompt ≠ obeyed.&lt;/strong&gt; Instruction and verification are separate; check against real data whether it was obeyed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Thread Through the Stumbles: "A Passing Test ≠ Behaving as Intended"
&lt;/h2&gt;

&lt;p&gt;Lined up, most of the stumbles had &lt;strong&gt;the same structure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The mapper "worked," but referenced the wrong place (②)&lt;/li&gt;
&lt;li&gt;The validation "passed," but wasn't meant to reject &lt;code&gt;0&lt;/code&gt; (③)&lt;/li&gt;
&lt;li&gt;The cache was "configured," but wasn't working (④)&lt;/li&gt;
&lt;li&gt;The prompt was "written," but wasn't obeyed (⑤)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are all gaps between "&lt;strong&gt;what I thought I did&lt;/strong&gt;" and "&lt;strong&gt;what's actually true.&lt;/strong&gt;" The lesson I'd grasped in an earlier project — &lt;strong&gt;"a passing test ≠ behaving as intended"&lt;/strong&gt; — applied directly in AI collaboration too.&lt;/p&gt;

&lt;p&gt;In AI collaboration, because code &lt;strong&gt;comes out fast and plausible-looking&lt;/strong&gt;, this "thought vs. actual" gap gets harder to see. Which is exactly why the value of &lt;strong&gt;keeping your verification in code and real data&lt;/strong&gt; went &lt;em&gt;up&lt;/em&gt;, I feel. The more something is AI-generated, the more you confirm it yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Most Important Thing Was the "Rejection"
&lt;/h2&gt;

&lt;p&gt;What stuck with me most in AI collaboration wasn't what AI wrote — it was &lt;strong&gt;rejecting an AI suggestion.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I used the openings of &lt;em&gt;The Tale of the Heike&lt;/em&gt; and &lt;em&gt;Hōjōki&lt;/em&gt; as read-aloud scripts, AI's review &lt;strong&gt;explained the two works' attributions backwards&lt;/strong&gt; (mixing up author and work). I verified against the literature, judged my implementation correct, and &lt;strong&gt;rejected AI's correction.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A small event, but it was a moment of demonstrating "the person is driving" through action. Don't swallow AI's suggestions whole; verify domain knowledge against primary sources. That accumulation underpins the trustworthiness of both the code and the article. When I write "I verified AI's suggestions" in the contest disclosure, this isn't abstract — it's a concrete episode I can tell.&lt;/p&gt;




&lt;h2&gt;
  
  
  A "Pattern" for Collaboration Took Shape
&lt;/h2&gt;

&lt;p&gt;Across several projects, my own pattern for collaboration came into view.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lay out option tables for direction / demo boilerplate / review&lt;/td&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Choose / decide the design / implement / verify / judge scope&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;AI is good at "lining up options," "producing a first draft," and "pointing out rough spots"; I take on "choosing," "deciding," and "confirming." &lt;strong&gt;Concepts by my own hand, boilerplate to AI — but always demanding an explanation and understanding it before taking it in.&lt;/strong&gt; This split was the realistic solution that let me balance the deadline with learning.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; It's close to designing a detailed interface yourself and delegating part of the implementation. The design is mine, the initial implementation is AI's — which is exactly why I read the delegated parts myself, rewrite them if needed, and keep the final responsibility.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;A reflection on building a real product through AI collaboration. Three points:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The question is not "&lt;strong&gt;did you use AI&lt;/strong&gt;" but "&lt;strong&gt;are you the one driving.&lt;/strong&gt;" Disclosure isn't a confession — it's presenting evidence of being the one driving.&lt;/li&gt;
&lt;li&gt;Most stumbles are gaps between "&lt;strong&gt;what I thought I did&lt;/strong&gt;" and "&lt;strong&gt;what's actual.&lt;/strong&gt;" "A passing test ≠ behaving as intended" was the thread through AI collaboration too.&lt;/li&gt;
&lt;li&gt;The most important thing was, more than what AI wrote, &lt;strong&gt;rejecting an AI suggestion.&lt;/strong&gt; Verify domain knowledge against primary sources.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Working with AI makes development faster. But the faster it goes, the more "thought-I-did" creeps in. So &lt;strong&gt;don't let go of the driving.&lt;/strong&gt; Keep the thinking and the verification with yourself, and collaborate on the wording and the boilerplate — that, for me right now, is "AI-collaborative development where the person is driving."&lt;/p&gt;

&lt;p&gt;That completes the series. You can trace the app's whole picture, each implementation, and the basis for the metrics from here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/uya0526design/measuring-japanese-read-aloud-speed-with-amivoice-timestamps-a-coaching-app-that-doesnt-stop-at-18e3"&gt;Main article — Measuring read-aloud with AmiVoice timestamps (overview)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uya0526design/calling-amivoices-synchronous-http-api-through-a-nextjs-bff-auth-multipart-order-and-the-webm-21o5"&gt;Satellite #1 — Implementing AmiVoice synchronous HTTP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uya0526design/dont-let-claude-haiku-do-the-math-a-two-stage-read-aloud-coach-design-and-the-prompt-swamp-2ihc"&gt;Satellite #2 — Claude Haiku coaching design and the prompt swamp&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/uya0526design/i-went-looking-for-the-basis-of-n-characters-per-minute-is-fast-there-wasnt-one-setting-4967"&gt;Satellite #3 — The rationale behind the metrics&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;📦 &lt;a href="https://github.com/uya0526-design/reading-speed-meter" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/reading-speed-meter&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article is a record of development and writing done with AI assistants (Claude / Cursor). The design, tech-selection, and verification decisions were made by the author. I collaborated with AI on the article's outline, structure, and draft prose, and reviewed and revised every line before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>cursor</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Went Looking for the Basis of 'N Characters Per Minute Is Fast' — There Wasn't One. Setting Read-Aloud Thresholds Honestly</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Thu, 18 Jun 2026 12:47:41 +0000</pubDate>
      <link>https://dev.to/uya0526design/i-went-looking-for-the-basis-of-n-characters-per-minute-is-fast-there-wasnt-one-setting-4967</link>
      <guid>https://dev.to/uya0526design/i-went-looking-for-the-basis-of-n-characters-per-minute-is-fast-there-wasnt-one-setting-4967</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Originally published in Japanese on Zenn.&lt;/strong&gt; This is the English version.&lt;br&gt;
Canonical: &lt;a href="https://zenn.dev/uya0526_design/articles/satellite3_metrics-rationale" rel="noopener noreferrer"&gt;https://zenn.dev/uya0526_design/articles/satellite3_metrics-rationale&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📚 This is &lt;strong&gt;satellite article #3&lt;/strong&gt; in my "Read-Aloud Speed Meter dev log" series. For the whole picture, see the &lt;a href="https://dev.to/uya0526design/measuring-japanese-read-aloud-speed-with-amivoice-timestamps-a-coaching-app-that-doesnt-stop-at-18e3"&gt;main article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where This Sits
&lt;/h2&gt;

&lt;p&gt;The read-aloud speed meter converts speaking speed into an &lt;strong&gt;evaluation label&lt;/strong&gt; like "slightly fast," and stagnation rate into one like "few." Those labels ultimately become the foundation for Claude Haiku's feedback.&lt;/p&gt;

&lt;p&gt;So — &lt;strong&gt;on what basis did I draw the thresholds (the dividing lines)?&lt;/strong&gt; This article digs into that "basis."&lt;/p&gt;

&lt;p&gt;The short answer from my research:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;I couldn't find a paper that defines an academic threshold for "N characters/min = fast/slow."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a record of &lt;strong&gt;how I drew the lines honestly&lt;/strong&gt; once I'd learned there was no firm basis. More than the metric numbers themselves, I believe being transparent about &lt;em&gt;why&lt;/em&gt; I chose those numbers is what makes an evaluation app trustworthy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 I'm an ex-Java engineer learning TypeScript in public. This one is mostly about design decisions.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why Obsess Over the "Basis"?
&lt;/h2&gt;

&lt;p&gt;An evaluation app &lt;strong&gt;passes judgment&lt;/strong&gt; on the user: "your reading is slightly fast." Once you're passing judgment, if you can't explain "why we can say that," it's just guesswork.&lt;/p&gt;

&lt;p&gt;This app in particular passes the labels straight to Claude Haiku to generate coaching. &lt;strong&gt;If the foundational label has an unclear basis, the feedback built on top of it is a castle on sand.&lt;/strong&gt; So I decided to nail down the basis for the thresholds first.&lt;/p&gt;

&lt;p&gt;Two things to research:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The judgment basis for &lt;strong&gt;speaking speed&lt;/strong&gt; (characters/min)&lt;/li&gt;
&lt;li&gt;The judgment basis for &lt;strong&gt;stagnation rate&lt;/strong&gt; (the proportion of silence)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As it turned out, these two had completely different &lt;em&gt;kinds&lt;/em&gt; of basis.&lt;/p&gt;




&lt;h2&gt;
  
  
  Speed Thresholds: No Academic Threshold → Draw From General Rules of Thumb
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I found
&lt;/h3&gt;

&lt;p&gt;For speaking speed, I first looked for academic backing. Here's what I found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Speaking speed has traditionally been measured against mora count, but &lt;strong&gt;prior research from the listener's perspective is itself scarce.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Japanese is considered a "fast language" with many syllables per second, yet there's research suggesting that, correlated with information density, &lt;strong&gt;the overall information-transfer rate is similar across languages.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But none of this serves as &lt;strong&gt;a basis for judging whether an individual's reading is fast or slow.&lt;/strong&gt; I couldn't find an academic threshold that says "N characters/min is fast."&lt;/p&gt;

&lt;h3&gt;
  
  
  The basis I adopted: the announcer standard
&lt;/h3&gt;

&lt;p&gt;So I adopted a &lt;strong&gt;rule-of-thumb basis.&lt;/strong&gt; The premise: &lt;strong&gt;the speed expected of an announcer is 300 characters/min.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The NHK announcer standard is "300 chars/min." It's shared knowledge across program staff and serves as a guide for writing scripts.&lt;/li&gt;
&lt;li&gt;The trend has been getting faster recently — said to be around 380 chars/min at NHK and 400 at commercial broadcasters.&lt;/li&gt;
&lt;li&gt;In settings that demand fast delivery, like sports play-by-play, over 400 chars/min is common.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Announcers are in the realm of &lt;strong&gt;language professionals.&lt;/strong&gt; So if an ordinary reader reaches this speed, I positioned that as &lt;strong&gt;already on the "slightly fast" side.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Thresholds (adopted version)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Speed label&lt;/th&gt;
&lt;th&gt;Chars/min&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;td&gt;– 149&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slightly slow&lt;/td&gt;
&lt;td&gt;150 – 199&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;td&gt;200 – 299&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slightly fast&lt;/td&gt;
&lt;td&gt;300 – 350&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;351 –&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is where &lt;strong&gt;an app-specific design decision&lt;/strong&gt; comes in. General tables usually put "the NHK standard, ~300 = standard," but this app &lt;strong&gt;intentionally shifts that&lt;/strong&gt;, placing the professional standard of 300 as the &lt;em&gt;start&lt;/em&gt; of "slightly fast" for an ordinary person.&lt;/p&gt;

&lt;p&gt;The reason is that this app's users are &lt;strong&gt;ordinary readers.&lt;/strong&gt; If I put the professional level at "standard," almost everyone gets a "slow" verdict, and the coaching pushes in a discouraging direction. "If an ordinary person reaches the professional level, that's already on the fast side" is a more natural framing for an encouragement tool.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Mapping into code:&lt;/strong&gt; These thresholds live as constants in &lt;code&gt;thresholds.ts&lt;/code&gt;. The explanation of the numbers' basis (300 = pro standard, etc.) isn't in code comments but in this article and the &lt;code&gt;LEARNING_LOG&lt;/code&gt;. In Java terms, it's the same as "extract config values into constants + keep the intent in the design docs."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Writing about it honestly in the article and UI
&lt;/h3&gt;

&lt;p&gt;What I was careful about when presenting the thresholds was &lt;strong&gt;not creating the misunderstanding&lt;/strong&gt; that "Claude is judging on strict academic grounds."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Since there's no academic threshold, write honestly that I &lt;strong&gt;adopted a rule-of-thumb basis (announcer standard, 300 chars/min).&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Spell out the &lt;strong&gt;intent of the custom placement&lt;/strong&gt; — putting the pro standard of 300 as the ordinary person's "slightly fast" start.&lt;/li&gt;
&lt;li&gt;Note that, more rigorously, &lt;strong&gt;chars/min is a simplified metric and mora/min is the usual measure.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rather than dressing it up as "strict science," I figured showing the process — "I drew the lines this way, on this basis" — earns more trust for an evaluation app.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stagnation-Rate Thresholds: There Isn't Even a "Ruler"
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Even less basis than speed
&lt;/h3&gt;

&lt;p&gt;For stagnation rate (the proportion of silence across the whole utterance), the conclusion was even tougher than for speed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The basis for a numeric threshold of "stagnation under X% is good" is even harder to find than for speed.&lt;/strong&gt; It's a domain where the "numeric ruler" itself, the kind speed has, simply doesn't exist.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's also thorny as a general principle. &lt;strong&gt;A speaker not taking pauses is not itself regarded as evidence of fluent speech.&lt;/strong&gt; In fact, research holds that pauses serve important functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Speech rate (including pauses) and articulation rate (the speaking portion only) are &lt;strong&gt;distinct concepts&lt;/strong&gt;, and the one including pauses correlates more strongly with auditory impression.&lt;/li&gt;
&lt;li&gt;Pauses don't just mark word/phrase/sentence boundaries — they serve the important function of &lt;strong&gt;giving the listener a chance to digest information.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, "fewer pauses = better" doesn't simply hold.&lt;/p&gt;

&lt;h3&gt;
  
  
  Accepting it as an "in-house metric"
&lt;/h3&gt;

&lt;p&gt;Here I made a design decision. For stagnation rate, I decided to &lt;strong&gt;claim no academic or general backing and accept it frankly as an in-house heuristic for this app.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stagnation label&lt;/th&gt;
&lt;th&gt;Stagnation rate (% after &lt;code&gt;labelMetrics&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Few&lt;/td&gt;
&lt;td&gt;– 0.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Somewhat few&lt;/td&gt;
&lt;td&gt;1.0 – 5.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Normal&lt;/td&gt;
&lt;td&gt;6.0 – 9.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Somewhat many&lt;/td&gt;
&lt;td&gt;10.0 – 19.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many&lt;/td&gt;
&lt;td&gt;20.0% –&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The basis for putting "0% on the good side" is cultural context
&lt;/h3&gt;

&lt;p&gt;Let me get ahead of the question a reader will naturally have: "&lt;strong&gt;Why put 0% (speaking without breaks) on the good side?&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;The basis isn't academic — it's the &lt;strong&gt;cultural context of Japanese.&lt;/strong&gt; Japanese has an expression, &lt;strong&gt;"tate-ita ni mizu"&lt;/strong&gt; (literally "water down a standing board" — speaking fluently and without interruption), reflecting a disposition to see &lt;strong&gt;uninterrupted, flowing speech as "fluent."&lt;/strong&gt; I drew the line leaning toward that connotation.&lt;/p&gt;

&lt;p&gt;That said, this isn't "the correct answer." As noted above, there's research finding that "pauses serve a function of letting the listener digest information," so &lt;strong&gt;0% isn't always good.&lt;/strong&gt; I judged this to be &lt;strong&gt;a domain that isn't academically settled and where people's perceptions differ&lt;/strong&gt;, and — within a deadline-bound Phase 1 — I didn't chase it deeper; as the designer I adopted the connotation on the "tate-ita ni mizu" side.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;This metric's limits:&lt;/strong&gt; As it stands, the stagnation rate does not distinguish Japan's &lt;strong&gt;meaningful pauses (&lt;em&gt;ma&lt;/em&gt;).&lt;/strong&gt; A 0.5-second stop that lands appropriately at a punctuation point and a 0.5-second stumble where the reader got stuck are both counted as the same "silence" stagnation. It really ought to be looked at qualitatively, paired with "appropriateness of the pause's position," and this is a research item not yet listed in the README's Phase 2. The app's footer makes this limit explicit too.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Two Metrics Had Different &lt;em&gt;Kinds&lt;/em&gt; of Basis
&lt;/h2&gt;

&lt;p&gt;To summarize, even for the same "evaluation-metric threshold," how I grounded them was completely different:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Academic threshold&lt;/th&gt;
&lt;th&gt;Basis adopted&lt;/th&gt;
&lt;th&gt;How I handle it in the article&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Rule of thumb (announcer 300 chars/min)&lt;/td&gt;
&lt;td&gt;Present an industry-custom value as basis; spell out the custom placement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stagnation rate&lt;/td&gt;
&lt;td&gt;None (no ruler at all)&lt;/td&gt;
&lt;td&gt;App-specific heuristic + cultural context&lt;/td&gt;
&lt;td&gt;Claim no academic backing; honestly label it an in-house metric&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the point I most want to land in this article. &lt;strong&gt;When you can't find a basis, don't force a "this is scientific" facade.&lt;/strong&gt; Disclosing the process — "there's no academic threshold, so I drew from a rule of thumb," "I accepted this as an in-house metric" — is, I think, actually &lt;em&gt;stronger&lt;/em&gt; for an evaluation app.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Decided Myself / What I Asked AI For
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;My decisions&lt;/td&gt;
&lt;td&gt;The final thresholds, the custom placement of the announcer standard, the call to treat stagnation as an in-house metric, adopting the "tate-ita ni mizu" context, stating the MVP limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asked AI for&lt;/td&gt;
&lt;td&gt;Presenting tentative boundary tables, helping survey related research and industry-custom values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verified myself&lt;/td&gt;
&lt;td&gt;Checking sources, the final judgment on the validity of the basis (not taking AI's research at face value)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The threshold numbers and their basis were all decided by me. AI helped with the survey and tentative drafts, but "which basis to adopt and how to place it" is the designer's judgment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This was a summary of "on what basis" I drew the read-aloud evaluation thresholds. Three takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;There was no academic threshold for speed&lt;/strong&gt;, so I grounded it in a rule of thumb (announcer 300 chars/min) and &lt;strong&gt;custom-placed&lt;/strong&gt; the pro standard as the ordinary person's "slightly fast" start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stagnation rate has no ruler at all&lt;/strong&gt;, so I accepted it as an app-specific heuristic; the basis for putting 0% on the good side rests on the cultural context of "tate-ita ni mizu."&lt;/li&gt;
&lt;li&gt;When there's no basis, &lt;strong&gt;don't dress it up as scientific — disclose the process.&lt;/strong&gt; That, I believe, is what makes an evaluation app honest.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An evaluation tool's value isn't decided by the precision of the numbers alone. It's &lt;strong&gt;whether you can be transparent with the user about how you justified those numbers.&lt;/strong&gt; I recorded even the dead ends of the basis hunt because that was the thing I most wanted to convey.&lt;/p&gt;

&lt;p&gt;The detailed development log is in the repository's &lt;code&gt;LEARNING_LOG&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;a href="https://github.com/uya0526-design/reading-speed-meter" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/reading-speed-meter&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Main references
&lt;/h4&gt;

&lt;p&gt;Sources I consulted while organizing the basis for this article.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ayumi Marushima, "A Study on the Cognition of Speech Rate," &lt;em&gt;Studies in Linguistics&lt;/em&gt; online inaugural issue (2008) [in Japanese]&lt;/li&gt;
&lt;li&gt;Ayumi Marushima, "A Study on the Tempo of Spoken Language," &lt;em&gt;Studies in Linguistics&lt;/em&gt; online no. 2 (2009) [in Japanese]&lt;/li&gt;
&lt;li&gt;Ayumi Marushima, "An Experimental-Phonetics Study of Speech Rate from the Speaker's and Listener's Perspectives," doctoral dissertation, University of Tsukuba (2015) [in Japanese]&lt;/li&gt;
&lt;li&gt;Reporting and shared industry knowledge on NHK / commercial-broadcaster announcer speech rates (Diamond Online, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 As this article argues, I believe an academic "speed threshold" &lt;strong&gt;does not exist.&lt;/strong&gt; The above aren't sources for the thresholds themselves, but background knowledge for "how to think about speech rate and pauses." I've checked each against the original.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Next time is the finale — I look back at the "AI-collaborative development" that underpinned all these design decisions → satellite #4, "Reflections on AI-collaborative development &amp;amp; a collection of stumbles" (&lt;a href="https://dev.to/uya0526design/not-did-you-use-ai-but-are-you-the-one-driving-reflections-on-building-a-real-product-through-464m"&gt;https://dev.to/uya0526design/not-did-you-use-ai-but-are-you-the-one-driving-reflections-on-building-a-real-product-through-464m&lt;/a&gt;).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is part of my public learning journey using AI tools (Claude / Cursor). The thresholds and the judgment of their basis are mine. I collaborate with AI on the article's structure, outline, and draft prose, and I review and revise every line before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>ai</category>
      <category>amivoice</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Don't Let Claude Haiku Do the Math — A Two-Stage Read-Aloud Coach Design, and the Prompt Swamp</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Thu, 18 Jun 2026 12:45:29 +0000</pubDate>
      <link>https://dev.to/uya0526design/dont-let-claude-haiku-do-the-math-a-two-stage-read-aloud-coach-design-and-the-prompt-swamp-2ihc</link>
      <guid>https://dev.to/uya0526design/dont-let-claude-haiku-do-the-math-a-two-stage-read-aloud-coach-design-and-the-prompt-swamp-2ihc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Originally published in Japanese on Zenn.&lt;/strong&gt; This is the English version.&lt;br&gt;
Canonical: &lt;a href="https://zenn.dev/uya0526_design/articles/satellite2_haiku-coaching" rel="noopener noreferrer"&gt;https://zenn.dev/uya0526_design/articles/satellite2_haiku-coaching&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📚 This is &lt;strong&gt;satellite article #2&lt;/strong&gt; in my "Read-Aloud Speed Meter dev log" series. For the whole picture, see the &lt;a href="https://dev.to/uya0526design/measuring-japanese-read-aloud-speed-with-amivoice-timestamps-a-coaching-app-that-doesnt-stop-at-18e3"&gt;main article&lt;/a&gt;; for the AmiVoice integration, see &lt;a href="https://dev.to/uya0526design/calling-amivoices-synchronous-http-api-through-a-nextjs-bff-auth-multipart-order-and-the-webm-21o5"&gt;satellite #1&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where This Sits
&lt;/h2&gt;

&lt;p&gt;This article covers the part of the read-aloud speed meter that hands the measured numbers to &lt;strong&gt;Claude Haiku&lt;/strong&gt; to generate coaching as "one compliment + one improvement."&lt;/p&gt;

&lt;p&gt;Many articles that use generative AI just dump it on the model: "here's the recognized text, evaluate it nicely." This article is the opposite. &lt;strong&gt;Don't let the LLM do the math.&lt;/strong&gt; All computation and rule-based decisions are settled in code, and Haiku only does the &lt;strong&gt;wording.&lt;/strong&gt; I'll go through how I designed this "two-stage" split, and which swamps I sank into during implementation.&lt;/p&gt;

&lt;p&gt;Four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why "code does the math, Haiku only does the wording" (the role-split design)&lt;/li&gt;
&lt;li&gt;The finalized prompt, and the &lt;code&gt;messages&lt;/code&gt; / &lt;code&gt;system&lt;/code&gt; / &lt;code&gt;cache_control&lt;/code&gt; implementation&lt;/li&gt;
&lt;li&gt;First-hand findings: &lt;strong&gt;the prompt cache that didn't work&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The moment real data slapped me with &lt;strong&gt;"written in the prompt ≠ obeyed"&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 I'm an ex-Java engineer learning TypeScript in public, so I drop in Java comparisons.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why Not Let Haiku Do the "Math"?
&lt;/h2&gt;

&lt;p&gt;Up front: &lt;strong&gt;Haiku is more than enough for feedback generation&lt;/strong&gt; — in fact, you can shape the task into something Haiku is good at. The key is the role split.&lt;/p&gt;

&lt;p&gt;I settle all the metric computation (speaking speed, stagnation rate, threshold decisions) &lt;strong&gt;entirely in code.&lt;/strong&gt; So by the time it reaches Haiku, it's already &lt;strong&gt;settled facts&lt;/strong&gt; like this (below is from running &lt;code&gt;labelMetrics&lt;/code&gt; on a real measurement — reading the &lt;em&gt;Heike&lt;/em&gt; sample during development; &lt;code&gt;stagnationRate&lt;/code&gt; is a number for percentage display):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pureSpeakingSpeed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;322&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pureSpeakingSpeedEvaluation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"slightly fast"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stagnationRate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stagnationRateEvaluation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"few"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Haiku's only job is to &lt;strong&gt;translate these numbers and labels into warm, concrete words.&lt;/strong&gt; That's exactly what small models are best at — wording, summarizing, tone — and it demands no numeric precision.&lt;/p&gt;

&lt;p&gt;Conversely, I clearly decided what &lt;strong&gt;not&lt;/strong&gt; to let Haiku do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Precise numeric computation (deriving speed or scores) → done in code&lt;/li&gt;
&lt;li&gt;Multi-step threshold branching (judging "is 322 chars/min fast?") → code interprets it as "slightly fast" before passing it on&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; This is exactly the separation of the Service layer (computation = code) from the presentation layer (wording = Haiku). Just as you don't write business logic in a template engine, I draw the line at "don't make the LLM write the decisions."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This split has a practical benefit beyond cost: &lt;strong&gt;pushing threshold decisions into code makes the output stable.&lt;/strong&gt; Asking Haiku to judge "is this fast?" every time wavers with its mood, but passing it the label "slightly fast" leaves only the wording free to vary.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Finalized Prompt
&lt;/h2&gt;

&lt;p&gt;Here's the system prompt (a static coach persona). After running it against real data several times, it settled into this shape. (Translated here for readability; the production version is in Japanese.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;You are a kind, specific coach who reviews Japanese read-aloud practice.

&lt;span class="gh"&gt;# Premises (strict)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; The JSON you receive is already-computed, already-evaluated fact.
&lt;span class="p"&gt;-&lt;/span&gt; Do not recompute numbers. Do not invent new metrics. Do not fill in
  facts that aren't in the JSON by guessing.
&lt;span class="p"&gt;-&lt;/span&gt; Speak only on the basis of what's written in the JSON.
&lt;span class="p"&gt;-&lt;/span&gt; Do not mention punctuation (samples can be short and may contain none).

&lt;span class="gh"&gt;# Output format (strict)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Polite register. Warm, calm tone.
&lt;span class="p"&gt;-&lt;/span&gt; No emoji, symbols, line breaks, headings, or preamble. Output exactly two sentences.

&lt;span class="gh"&gt;# Output format (best-effort)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; About 100 Japanese characters total.

&lt;span class="gh"&gt;# Output content&lt;/span&gt;
Sentence 1: Pick the single best point and praise it concretely.
Sentence 2: Give exactly one improvement, as a concrete action to try next.

&lt;span class="gh"&gt;# How to pick the improvement (top-down; only the first that matches)&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; "stagnationRateEvaluation" is "somewhat many" or worse → how to use pauses (ma)
&lt;span class="p"&gt;2.&lt;/span&gt; "pureSpeakingSpeedEvaluation" is "slightly slow"/"slow" → ease the tempo forward
&lt;span class="p"&gt;3.&lt;/span&gt; "pureSpeakingSpeedEvaluation" is "slightly fast"/"fast" → consciously take a breath and read slower
&lt;span class="p"&gt;4.&lt;/span&gt; None apply → pick the one most-improvable point and frame it positively as the next goal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three notes on the intent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I &lt;strong&gt;prioritize the improvements and emit only the first match.&lt;/strong&gt; I also added a fourth branch (a fallback) so the model isn't forced to hunt for flaws when everything is good.&lt;/li&gt;
&lt;li&gt;Without &lt;strong&gt;explicitly saying "exactly two sentences,"&lt;/strong&gt; it tacks on preamble or bullet points. I lock the purity of the output via the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"About 100 chars" is a best-effort goal.&lt;/strong&gt; I explain why below, but it's because LLMs can't count Japanese characters precisely.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  API Implementation: &lt;code&gt;system&lt;/code&gt; / &lt;code&gt;messages&lt;/code&gt; / &lt;code&gt;cache_control&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I call it via &lt;code&gt;messages.create&lt;/code&gt; from the Claude Messages API (&lt;code&gt;@anthropic-ai/sdk&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ANTHROPIC_MODEL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// e.g. claude-haiku-4-5&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FEEDBACK_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;cache_control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ephemeral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// goes inside the system block&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feedbackFacts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// settled facts only&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the structure means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;system&lt;/code&gt;&lt;/strong&gt; = Claude's persona and ground rules (outside the conversation). The static coach persona lives here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;messages&lt;/code&gt; &lt;code&gt;role: "user"&lt;/code&gt;&lt;/strong&gt; = the actual conversational input (the evaluation JSON that changes each time).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;role&lt;/code&gt;&lt;/strong&gt; is either &lt;code&gt;"user"&lt;/code&gt; (your input) or &lt;code&gt;"assistant"&lt;/code&gt; (Claude's reply). This is a single round-trip, so one &lt;code&gt;user&lt;/code&gt; entry is enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; A fixed &lt;code&gt;system&lt;/code&gt; prompt plus a variable &lt;code&gt;user&lt;/code&gt; payload is like passing request/response pairs as an array. For multi-turn conversations, you stack the history in order.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  How to think about &lt;code&gt;max_tokens&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;I initially set &lt;code&gt;1024&lt;/code&gt;, copied from an official example, but this output (two sentences) measured at &lt;strong&gt;about 88 tokens.&lt;/strong&gt; &lt;code&gt;max_tokens&lt;/code&gt; is "the maximum you may generate," not "the amount you must generate," so a generous value does almost no harm — generation stops naturally when the output ends. As a safety valve against unintended long output, I tightened it to &lt;code&gt;256&lt;/code&gt; as in the code above.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; It feels close to &lt;code&gt;StringBuilder&lt;/code&gt;'s &lt;code&gt;initialCapacity&lt;/code&gt;. Reserving large doesn't cost anything if you don't use it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Swamp ① — The Prompt Cache "Didn't Work"
&lt;/h2&gt;

&lt;p&gt;Here's where the first-hand, verify-it-yourself finding begins.&lt;/p&gt;

&lt;p&gt;Since the static &lt;code&gt;system&lt;/code&gt; prompt is identical every time, I figured I could make it cheaper with &lt;strong&gt;prompt caching.&lt;/strong&gt; I added &lt;code&gt;cache_control: { type: "ephemeral" }&lt;/code&gt;, did the break-even math (cache writes cost 1.25× the first time, reads are ~90% off afterward, so "it pays off after two uses"), and concluded "I should use this."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But it wasn't actually working.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reason is a &lt;strong&gt;minimum-token wall.&lt;/strong&gt; Claude Haiku 4.5's minimum cacheable size is &lt;strong&gt;4,096 tokens.&lt;/strong&gt; My &lt;code&gt;system&lt;/code&gt; prompt was a few hundred tokens — below the threshold. Even with &lt;code&gt;cache_control&lt;/code&gt; written, nothing was being cached.&lt;/p&gt;

&lt;p&gt;You can check via the response &lt;code&gt;usage&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// cache_creation_input_tokens: 0&lt;/span&gt;
&lt;span class="c1"&gt;// cache_read_input_tokens: 0   ← if both are 0, nothing was cached&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both were &lt;code&gt;0&lt;/code&gt;. I was so absorbed in the break-even math that &lt;strong&gt;the underlying minimum-token condition was my blind spot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Writing &lt;code&gt;cache_control&lt;/code&gt; does no operational harm (it's simply ignored). But writing "I optimized it with caching" in this app would be false. I decided the most honest — and most useful to the reader — thing was to keep it as &lt;strong&gt;a verification process: I thought it would help, checked, found it didn't meet the conditions, and it didn't help.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Lesson:&lt;/strong&gt; "The official feature exists" and "it helps in my use case" are different problems. If you don't check the preconditions — like a minimum token count — you can end up thinking you optimized while doing nothing.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Swamp ② — "Written in the Prompt ≠ Obeyed"
&lt;/h2&gt;

&lt;p&gt;The other swamp. Reading with real data, this happened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read a short ~10-second sample → the recognized text contains &lt;strong&gt;no punctuation&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Yet Haiku tacked on a concrete tip that wasn't in the input: &lt;strong&gt;"take a breath at the punctuation"&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cause was that the finalized prompt's rules had &lt;strong&gt;punctuation-dependent wording&lt;/strong&gt; like "settle at the punctuation." As a fix, I:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Added one line to the premises: "do not mention punctuation"&lt;/li&gt;
&lt;li&gt;Changed rule 3 from "settle at the punctuation" → "consciously take a breath and read slower" (punctuation-independent wording)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But there's &lt;strong&gt;no guarantee this is obeyed 100%.&lt;/strong&gt; LLMs can probabilistically break even a "do not ~" prohibition.&lt;/p&gt;

&lt;p&gt;In fact, the same run had another wobble. The input was stagnation "somewhat many" + speed "slow," which by the rules should prioritize &lt;strong&gt;rule 1 (how to use pauses).&lt;/strong&gt; But the output leaned toward rule 2 (tempo forward). This surfaced the limit of a design that writes the priority decision into the prompt and lets Haiku choose.&lt;/p&gt;

&lt;p&gt;What kicked in here is the same shape as a past lesson: &lt;strong&gt;"a passing test ≠ behaving as intended."&lt;/strong&gt; It's not about whether you &lt;em&gt;instructed&lt;/em&gt; it, but whether you &lt;strong&gt;verify against real data that it was obeyed.&lt;/strong&gt; "Written in the prompt" does not equal "obeyed."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; It's the same as writing "returns within 100 characters" in a method's Javadoc but still validating on the caller side. Writing a spec and the spec being upheld are separate things. Whether it was upheld is checked on the caller side (in code).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  If you want stability, "settle it in code and pass it in"
&lt;/h3&gt;

&lt;p&gt;To make the output fully stable, you can &lt;strong&gt;decide which improvement to emit in code&lt;/strong&gt; and pass it as a single field like &lt;code&gt;{ "improvementFocus": "how to use pauses" }&lt;/code&gt;. Then Haiku can focus only on wording, and the priority wobble disappears.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A (used this time)&lt;/td&gt;
&lt;td&gt;Write the priority in the prompt, let Haiku choose&lt;/td&gt;
&lt;td&gt;Looks smart, but wobbles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Settle the improvement focus in code, pass as one field&lt;/td&gt;
&lt;td&gt;Stable output, but rigid&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Due to the deadline I left it on approach A this time (watching it after the prompt fix), but I have a clear sense that if the wobble stands out in production, leaning to B is the sure bet. This too was a design-decision point: "looking smart vs. being stable."&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Handle the Character Limit (Phase 1)
&lt;/h2&gt;

&lt;p&gt;Having Haiku itself count "within 100 chars" isn't trustworthy. LLMs can't count Japanese characters precisely and routinely return over the limit. In Phase 1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep the prompt's "about 100 chars" as a &lt;strong&gt;best-effort goal&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Hold the actual ceiling with &lt;code&gt;max_tokens: 256&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code-side validation&lt;/strong&gt; (like truncating on overflow) is &lt;strong&gt;not implemented in Phase 1&lt;/strong&gt; (a future option)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As with swamp ②, the state here is "the prompt is the spec; the strict guarantee isn't in code yet."&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Implemented Myself / What I Asked AI For
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;My decisions / implementation&lt;/td&gt;
&lt;td&gt;The role split (compute = code / wording = Haiku), the finalized prompt text, the improvement priority order, the &lt;code&gt;max_tokens&lt;/code&gt; value, the cache verification, isolating and fixing the punctuation issue, the A/B trade-off judgment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asked AI for&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;messages.create&lt;/code&gt; skeleton, how to write the &lt;code&gt;system&lt;/code&gt; array + &lt;code&gt;cache_control&lt;/code&gt;, an example of how to check &lt;code&gt;usage&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed on AI's pointer&lt;/td&gt;
&lt;td&gt;Putting &lt;code&gt;cache_control&lt;/code&gt; inside the block rather than on a string &lt;code&gt;system&lt;/code&gt;, a sensible &lt;code&gt;max_tokens&lt;/code&gt; value, observing the punctuation wobble&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The prompt text, the role split, and the verification decisions are all mine. I ran it against real data, sank into the swamps, and isolated and fixed them myself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This was my design for generating read-aloud coaching feedback with Claude Haiku. Three takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;two-stage design — code does the math, Haiku only does the wording.&lt;/strong&gt; Keep the LLM on its best skill, translation, and settle the decisions in code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt caching didn't work in this app&lt;/strong&gt; (it doesn't reach Haiku's 4,096-token minimum). If you don't check the preconditions, "optimizing" becomes "pretending to."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Written in the prompt ≠ obeyed.&lt;/strong&gt; Verify the punctuation and priority wobbles against real data, and if you need stability, push the decisions into code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The thread running through it all is one point: &lt;strong&gt;instruction and verification are separate.&lt;/strong&gt; A prompt is a spec, not a guarantee — on that premise, keeping your verification in code and real data is, I believe, the honest stance when using generative AI in a real product.&lt;/p&gt;

&lt;p&gt;The detailed development log is in the repository's &lt;code&gt;LEARNING_LOG_Phase1_Step4.md&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;a href="https://github.com/uya0526-design/reading-speed-meter" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/reading-speed-meter&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time I dig into the rationale behind the metrics — on what basis I set the speed and stagnation-rate thresholds → satellite #3, "The rationale behind the metrics" (&lt;a href="https://dev.to/uya0526design/i-went-looking-for-the-basis-of-n-characters-per-minute-is-fast-there-wasnt-one-setting-4967"&gt;https://dev.to/uya0526design/i-went-looking-for-the-basis-of-n-characters-per-minute-is-fast-there-wasnt-one-setting-4967&lt;/a&gt;).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is part of my public learning journey using AI tools (Claude / Cursor). The design, prompt, and verification decisions are mine, and the output is checked against real data. I collaborate with AI on the article's structure, outline, and draft prose, and I review and revise every line before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>ai</category>
      <category>claude</category>
    </item>
    <item>
      <title>Calling AmiVoice's Synchronous HTTP API Through a Next.js BFF — Auth, multipart Order, and the WebM Trap</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Thu, 18 Jun 2026 12:42:49 +0000</pubDate>
      <link>https://dev.to/uya0526design/calling-amivoices-synchronous-http-api-through-a-nextjs-bff-auth-multipart-order-and-the-webm-21o5</link>
      <guid>https://dev.to/uya0526design/calling-amivoices-synchronous-http-api-through-a-nextjs-bff-auth-multipart-order-and-the-webm-21o5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Originally published in Japanese on Zenn.&lt;/strong&gt; This is the English version.&lt;br&gt;
Canonical: &lt;a href="https://zenn.dev/uya0526_design/articles/satellite1_amivoice-bff" rel="noopener noreferrer"&gt;https://zenn.dev/uya0526_design/articles/satellite1_amivoice-bff&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📚 This is &lt;strong&gt;satellite article #1&lt;/strong&gt; in my "Read-Aloud Speed Meter dev log" series. For the whole picture, see the &lt;a href="https://dev.to/uya0526design/measuring-japanese-read-aloud-speed-with-amivoice-timestamps-a-coaching-app-that-doesnt-stop-at-18e3"&gt;main article&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where This Sits
&lt;/h2&gt;

&lt;p&gt;In the read-aloud speed meter app, this article covers the part that sends browser-recorded audio to the &lt;strong&gt;AmiVoice API&lt;/strong&gt; to get back recognized text plus timestamps. The theme is &lt;strong&gt;calling an external API without exposing your API key to the browser&lt;/strong&gt; — in other words, implementing a &lt;strong&gt;BFF (Backend for Frontend).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The main article only touched the highlights, so here I go down to a level you can &lt;strong&gt;reproduce yourself.&lt;/strong&gt; Specifically, four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why you must not call AmiVoice directly from the browser (why a BFF is needed)&lt;/li&gt;
&lt;li&gt;AmiVoice's synchronous HTTP &lt;strong&gt;auth, parameters, and multipart order&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Why the browser's &lt;code&gt;MediaRecorder&lt;/code&gt; output (WebM/Opus) passes through as-is&lt;/li&gt;
&lt;li&gt;Reshaping the raw JSON with a pure-function mapper and testing it with fixtures&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 I'm an ex-Java engineer learning TypeScript in public, so I drop in comparisons to Java here and there.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why a BFF Is Needed
&lt;/h2&gt;

&lt;p&gt;Using the AmiVoice API requires an &lt;strong&gt;API key.&lt;/strong&gt; And that key must &lt;strong&gt;never&lt;/strong&gt; appear in browser-side code. Frontend JavaScript is fully inspectable by the user, so writing the key there leaks it instantly.&lt;/p&gt;

&lt;p&gt;So I insert a relay that holds the key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Browser] ──audio Blob──▶ [Next.js API Route (BFF / holds key)] ──▶ [AmiVoice API]
 record / display   audio field      reshape into u / d / a        speech → text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser only calls my own API Route (&lt;code&gt;/api/recognize&lt;/code&gt;), and that Route attaches the key server-side and forwards to AmiVoice. The key is just read from &lt;code&gt;process.env&lt;/code&gt; and never ends up in the bundle shipped to the browser.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; This is the same as a Spring &lt;code&gt;@RestController&lt;/code&gt; reading an external API key from &lt;code&gt;application.yml&lt;/code&gt; (env vars) and relaying without showing it to the client. Think "a thin Servlet that hides the secret and relays an external API." In Next.js, the &lt;code&gt;export async function POST&lt;/code&gt; in &lt;code&gt;app/api/recognize/route.ts&lt;/code&gt; corresponds to &lt;code&gt;@PostMapping&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  AmiVoice Synchronous HTTP API Spec
&lt;/h2&gt;

&lt;p&gt;I used the synchronous HTTP interface, implementing against the official manual and double-checking with curl. The key points:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST https://acp-api.amivoice.com/v1/nolog/recognize&lt;/code&gt; (no-log version)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;Put the API key in the multipart &lt;strong&gt;&lt;code&gt;u&lt;/code&gt;&lt;/strong&gt; part (&lt;strong&gt;not&lt;/strong&gt; an &lt;code&gt;Authorization&lt;/code&gt; header)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engine&lt;/td&gt;
&lt;td&gt;Engine name in &lt;strong&gt;&lt;code&gt;d&lt;/code&gt;&lt;/strong&gt; (e.g. &lt;code&gt;-a-general&lt;/code&gt; = general conversational)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;Binary in &lt;strong&gt;&lt;code&gt;a&lt;/code&gt;&lt;/strong&gt;. Must be the &lt;strong&gt;final multipart part&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;text&lt;/code&gt; at the top; per-word &lt;code&gt;starttime&lt;/code&gt;/&lt;code&gt;endtime&lt;/code&gt; in &lt;code&gt;results[].tokens[]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error check&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;code&lt;/code&gt; empty string &lt;code&gt;""&lt;/code&gt; = success; &lt;code&gt;code !== ""&lt;/code&gt; = failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I hit &lt;strong&gt;three traps&lt;/strong&gt; here. Let me share them in order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap ① — Auth is the &lt;code&gt;u&lt;/code&gt; field, not a header
&lt;/h3&gt;

&lt;p&gt;When I think "REST API auth," I think &lt;code&gt;Authorization: Bearer ...&lt;/code&gt;. AmiVoice's synchronous HTTP is different: &lt;strong&gt;the API key goes in a multipart field called &lt;code&gt;u&lt;/code&gt;.&lt;/strong&gt; My first version put it in a header, got a 401-style failure, and I stalled here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap ② — The audio &lt;code&gt;a&lt;/code&gt; goes &lt;em&gt;last&lt;/em&gt;
&lt;/h3&gt;

&lt;p&gt;The multipart parts need to be in the order &lt;strong&gt;&lt;code&gt;u&lt;/code&gt; → &lt;code&gt;d&lt;/code&gt; → &lt;code&gt;a&lt;/code&gt; (audio)&lt;/strong&gt;, with the audio as the final part. Adding another field after it caused the audio to be ignored. The order you &lt;code&gt;append&lt;/code&gt; to FormData carries meaning directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap ③ — WebM/Opus passes through as-is (&lt;code&gt;c&lt;/code&gt; can be omitted)
&lt;/h3&gt;

&lt;p&gt;The browser's &lt;code&gt;MediaRecorder&lt;/code&gt; usually outputs &lt;code&gt;audio/webm;codecs=opus&lt;/code&gt;. I braced for "I'll probably need to convert the format before handing it to AmiVoice," but &lt;strong&gt;WebM + Opus carries a header in the container, so the audio-format parameter &lt;code&gt;c&lt;/code&gt; can be omitted on synchronous HTTP&lt;/strong&gt; (verified with curl). You can send the recording Blob unconverted.&lt;/p&gt;




&lt;h2&gt;
  
  
  Implementing the API Route (BFF)
&lt;/h2&gt;

&lt;p&gt;Here's &lt;code&gt;app/api/recognize/route.ts&lt;/code&gt; with the traps accounted for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AMIVOICE_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://acp-api.amivoice.com/v1/nolog/recognize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AMIVOICE_ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-a-general&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// general conversational&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Receive the audio Blob from the browser under the "audio" field&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inForm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Reshape into u / d / a order for AmiVoice&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outForm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;outForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AMIVOICE_API_KEY&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// auth (not a header)&lt;/span&gt;
  &lt;span class="nx"&gt;outForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AMIVOICE_ENGINE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                    &lt;span class="c1"&gt;// engine&lt;/span&gt;
  &lt;span class="nx"&gt;outForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recording.webm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// audio (always last)&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Forward to synchronous HTTP and return the raw JSON as-is&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AMIVOICE_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outForm&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Simplified. In the real code, a missing &lt;code&gt;AMIVOICE_API_KEY&lt;/code&gt; returns 500 and never sends an empty key to AmiVoice.)&lt;/p&gt;

&lt;p&gt;The key idea is &lt;strong&gt;"two stages of FormData."&lt;/strong&gt; Browser → my Route receives under the &lt;code&gt;audio&lt;/code&gt; field; Route → AmiVoice reassembles into &lt;code&gt;u&lt;/code&gt;/&lt;code&gt;d&lt;/code&gt;/&lt;code&gt;a&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; It's the same shape as reshaping an inbound DTO into the multipart form for an outbound external API. "The shape you receive and the shape you send are different things" maps directly onto the FormData reshape.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I designed this Route to &lt;strong&gt;return AmiVoice's raw JSON almost untouched.&lt;/strong&gt; Rather than putting formatting in the Route, I leave it to the next mapper (a pure function), separating responsibilities.&lt;/p&gt;

&lt;h3&gt;
  
  
  Look at the raw JSON with curl first
&lt;/h3&gt;

&lt;p&gt;Before implementing, hitting the endpoint with curl to &lt;strong&gt;see the shape of the raw response&lt;/strong&gt; turned out to be the fastest route in the end.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://acp-api.amivoice.com/v1/nolog/recognize &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;u&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AMIVOICE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-a-general"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;a&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@recording.webm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I pass the API key from an environment variable (&lt;code&gt;$AMIVOICE_API_KEY&lt;/code&gt;) so I never hardcode a raw key in command history or code. "Keep the key out of the code" applies from the curl-verification stage onward.&lt;/p&gt;

&lt;p&gt;The returned JSON looks roughly like this (excerpted; values are examples):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"written"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"一番"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spoken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"いちばん"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"starttime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"endtime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1480&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"written"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"買っ"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spoken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"かっ"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"starttime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1480&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"endtime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1672&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"written"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"た"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spoken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"た"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"starttime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1720&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"endtime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"一番買った"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"一番買った"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Mapper: Raw JSON → App Type (a Pure Function)
&lt;/h2&gt;

&lt;p&gt;I reshape the raw JSON into the &lt;code&gt;AmiVoiceResponse&lt;/code&gt; type used inside the app. I make this conversion a &lt;strong&gt;pure function&lt;/strong&gt; with no I/O, sitting as a layer outside the measurement logic (&lt;code&gt;calculateMetrics&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AmiVoiceResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;starttime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;endtime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mapAmiVoiceResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;AmiVoiceResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;rawResponse&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;rawResponse&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rawResponse&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;starttime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;endtime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt; &lt;span class="p"&gt;}[];&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;starttime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;starttime&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;endtime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endtime&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was one fix here too. My first version referenced &lt;code&gt;raw.result.tokens&lt;/code&gt; (&lt;strong&gt;singular&lt;/strong&gt;) and got nothing back; I corrected it to &lt;code&gt;raw.results[0].tokens&lt;/code&gt; (&lt;strong&gt;a plural array&lt;/strong&gt;). It's a mistake &lt;strong&gt;I could have prevented by looking at the raw JSON with curl first&lt;/strong&gt;, and it reaffirmed how important it is to "get hold of real data early."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧭 &lt;strong&gt;Design decision:&lt;/strong&gt; You can build segments "per word" or "per utterance." I went with &lt;strong&gt;per word&lt;/strong&gt;, because the gaps (silence) between words then feed into the stagnation rate, letting me measure fluency more granularly.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Testing With Fixtures
&lt;/h2&gt;

&lt;p&gt;Since the mapper is a pure function, it's straightforward to test with Vitest. The key is to &lt;strong&gt;save the real data captured with curl as a fixture.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fixtures/
├── test_01.json   # short response (3 tokens)
└── test_02.json   # based on real curl data (9 tokens)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vitest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mapAmiVoiceResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./mapAmiVoiceResponse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;raw01&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../../fixtures/test_01.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mapAmiVoiceResponse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;builds segments from tokens and extracts text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapAmiVoiceResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw01&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Not just "the shape matches" — pin the concrete values&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;starttime&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;endtime&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1480&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;一番買った&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I was conscious of here is a lesson from a past project: &lt;strong&gt;"a passing test ≠ behaving as intended."&lt;/strong&gt; If you only compare the result of &lt;code&gt;.map()&lt;/code&gt; against another &lt;code&gt;.map()&lt;/code&gt;, you end up writing the test with the same logic as the mapper, which doesn't verify intent. So by &lt;strong&gt;hardcoding concrete values like &lt;code&gt;starttime: 1080&lt;/code&gt; and asserting on them&lt;/strong&gt;, I locked down "is it really pulling the correct values?"&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; Fixtures are like JUnit test resources (JSON under &lt;code&gt;src/test/resources&lt;/code&gt;); &lt;code&gt;toHaveLength&lt;/code&gt; / concrete-value asserts correspond to &lt;code&gt;assertEquals&lt;/code&gt;. The idea of "pinning production-like raw JSON into your tests" carried over directly.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What I Implemented Myself / What I Asked AI For
&lt;/h2&gt;

&lt;p&gt;This is AI-collaborative development, so for transparency I write out the split.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;My decisions / implementation&lt;/td&gt;
&lt;td&gt;Adopting the BFF structure, the &lt;code&gt;u&lt;/code&gt;/&lt;code&gt;d&lt;/code&gt;/&lt;code&gt;a&lt;/code&gt; order, the raw-JSON-return policy, choosing per-word segments, concrete-value asserts in fixtures, checking the official manual, curl verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asked AI for&lt;/td&gt;
&lt;td&gt;The POST handler skeleton, &lt;code&gt;fetch&lt;/code&gt; / &lt;code&gt;FormData&lt;/code&gt; boilerplate, a first-draft mapper example, examples of how to write the tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed on AI's pointer&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;result&lt;/code&gt; → &lt;code&gt;results[0]&lt;/code&gt;, &lt;code&gt;Authorization&lt;/code&gt; header → &lt;code&gt;u&lt;/code&gt; field, a weak &lt;code&gt;.map()&lt;/code&gt;-only test → concrete-value asserts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;AI provided boilerplate examples, but &lt;strong&gt;the rewrites to match the official spec, the verification, and the fixes were all mine.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This was my record of calling AmiVoice's synchronous HTTP API through a Next.js BFF. The takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep the API key out of the browser.&lt;/strong&gt; Use a BFF that relays through Next.js API Routes.&lt;/li&gt;
&lt;li&gt;For synchronous HTTP, auth is the &lt;strong&gt;&lt;code&gt;u&lt;/code&gt; field&lt;/strong&gt;, the audio &lt;code&gt;a&lt;/code&gt; is the &lt;strong&gt;final part&lt;/strong&gt;, and WebM/Opus lets you &lt;strong&gt;omit &lt;code&gt;c&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Reshape the raw JSON with a &lt;strong&gt;pure-function mapper&lt;/strong&gt;, and test it with &lt;strong&gt;a fixture of real curl data + concrete-value asserts.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most of my stumbles were ones I &lt;strong&gt;could have prevented by looking at the raw response first.&lt;/strong&gt; When working with an external API, hit it once with curl and look at the raw JSON with your own eyes before implementing — that order turned out to be the fastest.&lt;/p&gt;

&lt;p&gt;The detailed development log is in the repository's &lt;code&gt;LEARNING_LOG_Phase1_Step3.md&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;a href="https://github.com/uya0526-design/reading-speed-meter" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/reading-speed-meter&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time I cover the "two-stage" design that hands the recognized result (settled facts) to &lt;strong&gt;Claude Haiku&lt;/strong&gt; to generate one-line feedback → satellite #2, "Claude Haiku coaching design and the prompt swamp" (&lt;a href="https://dev.to/uya0526design/dont-let-claude-haiku-do-the-math-a-two-stage-read-aloud-coach-design-and-the-prompt-swamp-2ihc"&gt;https://dev.to/uya0526design/dont-let-claude-haiku-do-the-math-a-two-stage-read-aloud-coach-design-and-the-prompt-swamp-2ihc&lt;/a&gt;).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is part of my public learning journey using AI tools (Claude / Cursor). The design, tech selection, and implementation decisions are mine, and the code is verified with Vitest. I collaborate with AI on the article's structure, outline, and draft prose, and I review and revise every line before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>ai</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Measuring Japanese Read-Aloud Speed with AmiVoice Timestamps — A Coaching App That Doesn't Stop at STT-to-Claude</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Thu, 18 Jun 2026 12:41:47 +0000</pubDate>
      <link>https://dev.to/uya0526design/measuring-japanese-read-aloud-speed-with-amivoice-timestamps-a-coaching-app-that-doesnt-stop-at-18e3</link>
      <guid>https://dev.to/uya0526design/measuring-japanese-read-aloud-speed-with-amivoice-timestamps-a-coaching-app-that-doesnt-stop-at-18e3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Originally published in Japanese on Zenn.&lt;/strong&gt; This is the English version.&lt;br&gt;
Canonical: &lt;a href="https://zenn.dev/uya0526_design/articles/main_article_reading-speed-meter" rel="noopener noreferrer"&gt;https://zenn.dev/uya0526_design/articles/main_article_reading-speed-meter&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Introduction — What I Built
&lt;/h2&gt;

&lt;p&gt;I built a web app that lets you &lt;strong&gt;read a Japanese passage aloud, measures your speed and fluency, and has an AI coach return a one-line piece of feedback.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🌐 Demo: &lt;a href="https://reading-speed-meter.vercel.app/" rel="noopener noreferrer"&gt;https://reading-speed-meter.vercel.app/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📦 Repository: &lt;a href="https://github.com/uya0526-design/reading-speed-meter" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/reading-speed-meter&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flow is simple. You read a passage aloud into the mic (up to 10 seconds) while looking at the script — I prepared the opening lines of two Japanese classics, &lt;em&gt;The Tale of the Heike&lt;/em&gt; and &lt;em&gt;Hōjōki&lt;/em&gt; — and when you press &lt;strong&gt;"Measure,"&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AmiVoice API&lt;/strong&gt; recognizes the audio,&lt;/li&gt;
&lt;li&gt;the code computes your &lt;strong&gt;pure speaking speed (characters/min) and stagnation rate&lt;/strong&gt; from that result, and&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Haiku&lt;/strong&gt; returns coaching as "one compliment + one improvement."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;(Measurement starts on a button press after recording — it never runs automatically.)&lt;/p&gt;

&lt;p&gt;This article aims to be a single, self-contained piece covering the &lt;strong&gt;whole picture, the design decisions, and the reproduction steps&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Where this sits in my journey&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm an ex-Java engineer learning TypeScript and Python in public. This was my first project where I deliberately adopted "AI-collaborative development" as a clear mode. Throughout, I'll drop in &lt;strong&gt;comparisons to Java&lt;/strong&gt; — hopefully useful for anyone coming from a similar background.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What You'll Get From This Article
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;How to design evaluation logic that &lt;strong&gt;fully exploits the per-word timestamps&lt;/strong&gt; AmiVoice returns&lt;/li&gt;
&lt;li&gt;How to build a &lt;strong&gt;BFF (Backend for Frontend)&lt;/strong&gt; so API keys never reach the browser&lt;/li&gt;
&lt;li&gt;A "two-stage" design where &lt;strong&gt;code does the math&lt;/strong&gt; and &lt;strong&gt;Claude Haiku only does the wording&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-hand findings you only learn by verifying&lt;/strong&gt; — e.g., "I thought I'd optimized it, but it wasn't actually working"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I include concrete endpoints, parameters, and environment variables so you can reproduce it yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Learning Style (AI Transparency)
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Learning companions &amp;amp; how this article is written&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;Claude Pro&lt;/strong&gt; and &lt;strong&gt;Cursor Pro&lt;/strong&gt; as learning companions. This project had a deadline (the AmiVoice-sponsored contest in Zennfes Spring 2026), so I stepped one notch beyond my usual "I write every line myself" style into &lt;strong&gt;AI-collaborative development&lt;/strong&gt;. I'm stating that plainly rather than blurring it.&lt;/p&gt;

&lt;p&gt;The axis of disclosure is &lt;strong&gt;not "did I use AI"&lt;/strong&gt; but &lt;strong&gt;"am I the one driving."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tech selection, evaluation-algorithm design, architecture decisions, code verification&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boilerplate examples (recording, fetch, API Route skeletons)&lt;/td&gt;
&lt;td&gt;AI (Claude / Cursor)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Article structure, outline, draft prose, translation&lt;/td&gt;
&lt;td&gt;In collaboration with Claude&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reviewing and revising all content before publishing&lt;/td&gt;
&lt;td&gt;Me&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In other words: &lt;strong&gt;the thinking is mine, the wording is AI-assisted, and I verify all of it.&lt;/strong&gt; Every design decision, every threshold rationale, and every "where I got stuck" is my own first-hand information. I keep a per-step &lt;code&gt;LEARNING_LOG&lt;/code&gt; in the repository separating "what I implemented myself" from "what I asked AI for."&lt;/p&gt;




&lt;h2&gt;
  
  
  Originality — Why Not Stop at "STT → Claude"?
&lt;/h2&gt;

&lt;p&gt;A lot of articles combining speech-to-text (STT) with generative AI stop at "&lt;strong&gt;transcribe the audio, then hand the text straight to Claude.&lt;/strong&gt;" That works, but it throws away &lt;strong&gt;the best part of AmiVoice.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AmiVoice isn't just transcription. For &lt;strong&gt;each individual word&lt;/strong&gt;, it returns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;written&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Surface form (kanji + kana)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spoken&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reading (kana)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;confidence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Confidence score (0–1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;starttime&lt;/code&gt; / &lt;code&gt;endtime&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Word start/end time (milliseconds)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Using &lt;code&gt;starttime&lt;/code&gt; / &lt;code&gt;endtime&lt;/code&gt;, you know &lt;strong&gt;when, which word, and over how long&lt;/strong&gt; it was read. So my approach is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Get depth from the evaluation logic (code), then turn it into something human with Claude Haiku — a two-stage design.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In education and phonetics, read-aloud fluency is traditionally discussed along three axes: &lt;strong&gt;accuracy, speed, and expressiveness.&lt;/strong&gt; This app (Phase 1) implements &lt;strong&gt;speed&lt;/strong&gt; primarily, and adds &lt;strong&gt;stagnation rate (the proportion of pauses)&lt;/strong&gt; as an in-house metric that's the flip side of speed. Pauses are academically treated as part of speech rate, so I position stagnation as a speed-adjacent metric.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;Metric in this app&lt;/th&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;Pure speaking speed (chars/min) / stagnation rate (in-house, speed-adjacent)&lt;/td&gt;
&lt;td&gt;Phase 1 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accuracy&lt;/td&gt;
&lt;td&gt;Matching via edit distance&lt;/td&gt;
&lt;td&gt;Phase 2 (planned — see README)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expressiveness (intonation, etc.)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Not in scope for Phase 1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Read-aloud evaluation that leans hard on the time information" turned out to be a rare sweet spot — low cost (just arithmetic) yet genuinely distinctive.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture — Keep API Keys Out of the Browser
&lt;/h2&gt;

&lt;p&gt;Both AmiVoice and Claude &lt;strong&gt;require API keys&lt;/strong&gt;, and those keys must &lt;strong&gt;never&lt;/strong&gt; be exposed to the browser. So I insert a thin relay (BFF / proxy) that holds the keys.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Browser]
  Show script / record (getUserMedia → MediaRecorder → Blob)
        │  FormData(audio)
        ▼
[Next.js API Routes (BFF / holds keys)]
  /api/recognize  → AmiVoice synchronous HTTP (speech → text)
  /api/feedback   → Claude Messages API (one-line feedback)
        │
        ▼
[Browser]  Show metrics → show AI feedback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser never calls the two external APIs directly. Next.js API Routes act as a thin key-holding relay.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; This is the same idea as a Spring &lt;code&gt;@RestController&lt;/code&gt; reading an external API key from &lt;code&gt;application.yml&lt;/code&gt;, never showing it to the client, and relaying the call. Think of it as "a thin Servlet that relays an external API without leaking the secret."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Tech stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;Next.js 16 (App Router) + API Routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recording&lt;/td&gt;
&lt;td&gt;MediaRecorder API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speech recognition&lt;/td&gt;
&lt;td&gt;AmiVoice API (synchronous HTTP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI feedback&lt;/td&gt;
&lt;td&gt;Claude API (Haiku / &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing&lt;/td&gt;
&lt;td&gt;Vitest (28 tests)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I considered splitting the backend off into Python (FastAPI), but to hit the deadline I consolidated on Next.js: &lt;strong&gt;one language, one repository, one deploy.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Evaluation Logic (Code Side)
&lt;/h2&gt;

&lt;p&gt;All computation and threshold decisions are &lt;strong&gt;settled in code.&lt;/strong&gt; This is where the "depth" comes from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pure speaking speed and stagnation rate (a pure function)
&lt;/h3&gt;

&lt;p&gt;I keep everything inside a pure function &lt;code&gt;calculateMetrics&lt;/code&gt; that holds no I/O, so it's directly testable with Vitest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Input: a type reshaped from the AmiVoice response&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AmiVoiceResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;starttime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;endtime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}[];&lt;/span&gt; &lt;span class="c1"&gt;// milliseconds&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ReadingMetrics&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;pureSpeakingSpeed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// chars/min&lt;/span&gt;
  &lt;span class="nl"&gt;stagnationRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// 0–1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AmiVoiceResponse&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ReadingMetrics&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pureSpeakingSpeed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stagnationRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalSpeakingTimeMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endtime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;starttime&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalSpeakingTimeMs&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pureSpeakingSpeed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stagnationRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pureSpeakingSpeed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalSpeakingTimeMs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalElapsedTimeMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;endtime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;starttime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalElapsedTimeMs&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pureSpeakingSpeed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stagnationRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;stagnationRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalElapsedTimeMs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;totalSpeakingTimeMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;totalElapsedTimeMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;stagnationRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stagnationRate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pureSpeakingSpeed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stagnationRate&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Character count is based on the recognized text, in code points:&lt;/strong&gt; &lt;code&gt;[...text].length&lt;/code&gt;. Basing it on the original script would over-estimate speed when the reader skips ahead, so I avoided that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the numeric type with &lt;code&gt;Math.round&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;toFixed&lt;/code&gt; returns a string, so I don't use it.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; &lt;code&gt;[...text].length&lt;/code&gt; is a character count that accounts for surrogate pairs; &lt;code&gt;reduce&lt;/code&gt; corresponds to &lt;code&gt;stream().mapToLong().sum()&lt;/code&gt;. The division-by-zero guards and boundary tests are exactly the kind of error-path and boundary-value analysis you write all the time in JUnit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Decide thresholds &lt;em&gt;honestly&lt;/em&gt;
&lt;/h3&gt;

&lt;p&gt;Next, &lt;code&gt;labelMetrics&lt;/code&gt; converts the numbers into evaluation labels ("slightly fast," etc.). The hard question: &lt;strong&gt;how many characters/min counts as "fast"?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Honestly: &lt;strong&gt;I couldn't find an academic threshold for "N chars/min = fast/slow."&lt;/strong&gt; So for speed I leaned on a general rule of thumb — &lt;strong&gt;a news announcer's pace ≈ 300 chars/min&lt;/strong&gt; — and intentionally placed that professional level as the &lt;em&gt;start&lt;/em&gt; of "slightly fast" for an ordinary person. For stagnation rate there's no numeric standard at all, so I treat it frankly as an &lt;strong&gt;in-house heuristic.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Speed label&lt;/th&gt;
&lt;th&gt;Chars/min&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;td&gt;– 149&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slightly slow&lt;/td&gt;
&lt;td&gt;150 – 199&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;td&gt;200 – 299&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slightly fast&lt;/td&gt;
&lt;td&gt;300 – 350&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;351 –&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;The reasoning behind these lines — the in-house placement, why I treat stagnation as an in-house metric, the cultural context of "fluent as water off a board," and the relation to mora — is something I dig into in a separate satellite article: &lt;strong&gt;"The rationale behind the metrics" (&lt;a href="https://dev.to/uya0526design/i-went-looking-for-the-basis-of-n-characters-per-minute-is-fast-there-wasnt-one-setting-4967"&gt;https://dev.to/uya0526design/i-went-looking-for-the-basis-of-n-characters-per-minute-is-fast-there-wasnt-one-setting-4967&lt;/a&gt;).&lt;/strong&gt; Here, just hold onto the principle: &lt;strong&gt;when there's no basis, don't dress it up as science — disclose the process and draw the line.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;MVP limits (stated honestly):&lt;/strong&gt; Right now there's no matching of the script against the recognized text (accuracy is Phase 2 in the README), and the stagnation rate doesn't distinguish Japan's &lt;em&gt;meaningful&lt;/em&gt; pauses (&lt;em&gt;ma&lt;/em&gt;). The latter is a research item not yet listed even in the README's Phase 2. Both are spelled out in the app's footer.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Exploiting AmiVoice Fully
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/api/recognize&lt;/code&gt; receives the recording Blob and relays it to AmiVoice's &lt;strong&gt;synchronous HTTP API.&lt;/strong&gt; Here are the spots that trip people up. (Full implementation and a deeper dive are in satellite #1, &lt;strong&gt;"Implementing AmiVoice synchronous HTTP" (&lt;a href="https://dev.to/uya0526design/calling-amivoices-synchronous-http-api-through-a-nextjs-bff-auth-multipart-order-and-the-webm-21o5"&gt;https://dev.to/uya0526design/calling-amivoices-synchronous-http-api-through-a-nextjs-bff-auth-multipart-order-and-the-webm-21o5&lt;/a&gt;).&lt;/strong&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AMIVOICE_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://acp-api.amivoice.com/v1/nolog/recognize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AMIVOICE_ENGINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-a-general&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// general conversational&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inForm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outForm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;outForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AMIVOICE_API_KEY&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// auth&lt;/span&gt;
  &lt;span class="nx"&gt;outForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AMIVOICE_ENGINE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                    &lt;span class="c1"&gt;// engine&lt;/span&gt;
  &lt;span class="nx"&gt;outForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recording.webm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// audio (must be last)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AMIVOICE_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outForm&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Simplified. In the real code, a missing &lt;code&gt;AMIVOICE_API_KEY&lt;/code&gt; returns 500 and never sends an empty key to AmiVoice.)&lt;/p&gt;

&lt;p&gt;Gotchas I confirmed against both the official manual &lt;em&gt;and&lt;/em&gt; curl:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth is the multipart &lt;code&gt;u&lt;/code&gt; field, not an &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/strong&gt; I assumed header auth at first and got stuck here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audio &lt;code&gt;a&lt;/code&gt; must be the final multipart part.&lt;/strong&gt; Add another field after it and it gets ignored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebM + Opus carries a header in the container, so you can omit the audio-format parameter &lt;code&gt;c&lt;/code&gt; on synchronous HTTP&lt;/strong&gt; (verified with curl). The browser's &lt;code&gt;MediaRecorder&lt;/code&gt; output (&lt;code&gt;audio/webm;codecs=opus&lt;/code&gt;) went through as-is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I reshape the raw JSON into &lt;code&gt;AmiVoiceResponse&lt;/code&gt; with a pure-function mapper before passing it to &lt;code&gt;calculateMetrics&lt;/code&gt;. &lt;code&gt;text&lt;/code&gt; is top-level; &lt;code&gt;segments&lt;/code&gt; are built from &lt;code&gt;starttime&lt;/code&gt; / &lt;code&gt;endtime&lt;/code&gt; in &lt;code&gt;results[0].tokens&lt;/code&gt; — and yes, I initially referenced &lt;code&gt;result&lt;/code&gt; (singular) and had to fix it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Claude Haiku's "Two-Stage" Design
&lt;/h2&gt;

&lt;p&gt;For feedback generation I'm strict about the split: &lt;strong&gt;code does the math, Haiku only does the wording.&lt;/strong&gt; By the time data reaches Haiku, it's already &lt;strong&gt;settled facts&lt;/strong&gt; — for example, a real measurement during development (reading the &lt;em&gt;Heike&lt;/em&gt; sample) ran &lt;code&gt;labelMetrics&lt;/code&gt; to "speed = 322 chars/min, label = slightly fast, stagnation = 0%, label = few." Haiku's only job is to &lt;strong&gt;translate that into warm words.&lt;/strong&gt; I keep the small model on what it's best at — wording and tone — and never demand numeric precision from it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ANTHROPIC_MODEL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// e.g. claude-haiku-4-5&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FEEDBACK_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cache_control&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ephemeral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feedbackFacts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// settled facts only&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;system&lt;/code&gt; = persona and ground rules (a static coach persona)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;messages&lt;/code&gt; &lt;code&gt;role: "user"&lt;/code&gt; = the dynamic data that changes each time (the evaluation JSON)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;☕ &lt;strong&gt;Java comparison:&lt;/strong&gt; A fixed &lt;code&gt;system&lt;/code&gt; prompt plus a variable &lt;code&gt;user&lt;/code&gt; payload is the same idea as separating the Service layer (computation = code) from the presentation layer (wording = Haiku).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The "optimization" that wasn't working
&lt;/h3&gt;

&lt;p&gt;This is my biggest first-hand finding this time. I added &lt;code&gt;cache_control&lt;/code&gt; to the static &lt;code&gt;system&lt;/code&gt; prompt to make it cheaper via &lt;strong&gt;prompt caching.&lt;/strong&gt; I even did the break-even math and concluded "it pays off after two uses."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It wasn't working at all.&lt;/strong&gt; Claude Haiku 4.5's &lt;strong&gt;minimum cacheable size is 4,096 tokens&lt;/strong&gt;, and my &lt;code&gt;system&lt;/code&gt; prompt was a few hundred. It didn't meet the threshold, so even with &lt;code&gt;cache_control&lt;/code&gt; written, nothing was cached. (Confirmed via the response &lt;code&gt;usage&lt;/code&gt;: both &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; and &lt;code&gt;cache_read_input_tokens&lt;/code&gt; were &lt;code&gt;0&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;Writing "I optimized it with caching" would be false. So I'm keeping it in the article as &lt;strong&gt;a verification process: I thought it would help, checked, found it didn't meet the conditions, and it didn't help.&lt;/strong&gt; Not casually claiming "optimized" is, to me, part of being honest.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Written in the prompt ≠ obeyed"
&lt;/h3&gt;

&lt;p&gt;One more. A ~10-second read produces recognized text with &lt;strong&gt;no punctuation.&lt;/strong&gt; Yet Haiku tacked on a &lt;strong&gt;concrete tip that wasn't in the input&lt;/strong&gt;: "take a breath at the punctuation." I added a line to the prompt — "don't mention punctuation" — but even that isn't a 100% guarantee.&lt;/p&gt;

&lt;p&gt;This is the same shape as a lesson from a past project: &lt;strong&gt;"a passing test ≠ behaving as intended."&lt;/strong&gt; What matters isn't whether you &lt;em&gt;instructed&lt;/em&gt; something, but whether you &lt;strong&gt;verify against real data that it was obeyed.&lt;/strong&gt; If you ultimately want fully stable output, you can push the improvement focus into code and pass it as a single field (a Phase 2 option).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The finalized prompt, the full cache-verification details, and how I isolated the punctuation issue are in satellite #2, &lt;strong&gt;"Claude Haiku coaching design and the prompt swamp" (&lt;a href="https://dev.to/uya0526design/dont-let-claude-haiku-do-the-math-a-two-stage-read-aloud-coach-design-and-the-prompt-swamp-2ihc"&gt;https://dev.to/uya0526design/dont-let-claude-haiku-do-the-math-a-two-stage-read-aloud-coach-design-and-the-prompt-swamp-2ihc&lt;/a&gt;).&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Reproduction Steps
&lt;/h2&gt;

&lt;p&gt;The minimum to run it locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/uya0526-design/reading-speed-meter
&lt;span class="nb"&gt;cd &lt;/span&gt;reading-speed-meter
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;.env.local&lt;/code&gt; at the project root (never commit it):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;AMIVOICE_API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your_amivoice_api_key&lt;/span&gt;
&lt;span class="py"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your_anthropic_api_key&lt;/span&gt;
&lt;span class="py"&gt;ANTHROPIC_MODEL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;claude-haiku-4-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev    &lt;span class="c"&gt;# → http://localhost:3000&lt;/span&gt;
npm &lt;span class="nb"&gt;test&lt;/span&gt;       &lt;span class="c"&gt;# Vitest (28 tests)&lt;/span&gt;
npm run build  &lt;span class="c"&gt;# production build check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To deploy on Vercel: import from GitHub → confirm the Framework Preset is detected as &lt;strong&gt;Next.js&lt;/strong&gt; → add the three &lt;strong&gt;Environment Variables&lt;/strong&gt; above → Deploy. Crucially, &lt;strong&gt;none&lt;/strong&gt; of the three should have a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix (that would expose them to the browser). Recording needs mic permission, so it runs over HTTPS (Vercel's public URL) or &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Design Decisions &amp;amp; Gotchas — Highlights
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;What happened / how I decided&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AmiVoice auth&lt;/td&gt;
&lt;td&gt;Assumed an &lt;code&gt;Authorization&lt;/code&gt; header → found synchronous HTTP uses the &lt;code&gt;u&lt;/code&gt; field; fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio part order&lt;/td&gt;
&lt;td&gt;Put &lt;code&gt;a&lt;/code&gt; (audio) as the last part — anything after it is ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebM/Opus&lt;/td&gt;
&lt;td&gt;Header present, so &lt;code&gt;c&lt;/code&gt; can be omitted; &lt;code&gt;MediaRecorder&lt;/code&gt; output passes as-is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt cache&lt;/td&gt;
&lt;td&gt;Thought it would help, but didn't meet Haiku's 4,096-token minimum — &lt;strong&gt;it didn't work&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Punctuation leak&lt;/td&gt;
&lt;td&gt;Haiku mentioned punctuation that wasn't in the input → fixed the prompt (no full guarantee)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Char-count limit&lt;/td&gt;
&lt;td&gt;"Within 100 chars" — LLMs can't count precisely, so I demoted it to a &lt;strong&gt;best-effort goal&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Classics attribution&lt;/td&gt;
&lt;td&gt;AI explained the &lt;em&gt;Heike&lt;/em&gt; / &lt;em&gt;Hōjōki&lt;/em&gt; openings &lt;strong&gt;backwards&lt;/strong&gt; → I verified against sources and rejected it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last one symbolizes "the human is the one driving" in AI-collaborative development. Don't take AI's suggestions at face value; verify domain knowledge against primary sources. That accumulation is what makes this article and the implementation trustworthy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Satellite Articles (Deep Dives)
&lt;/h2&gt;

&lt;p&gt;This article is the overview. Each topic is broken out separately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;👉 &lt;strong&gt;This article (overview &amp;amp; MVP design)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Satellite #1 — Implementing AmiVoice synchronous HTTP: BFF, auth, mapper (&lt;a href="https://dev.to/uya0526design/calling-amivoices-synchronous-http-api-through-a-nextjs-bff-auth-multipart-order-and-the-webm-21o5"&gt;https://dev.to/uya0526design/calling-amivoices-synchronous-http-api-through-a-nextjs-bff-auth-multipart-order-and-the-webm-21o5&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Satellite #2 — Claude Haiku coaching design and the prompt swamp (&lt;a href="https://dev.to/uya0526design/dont-let-claude-haiku-do-the-math-a-two-stage-read-aloud-coach-design-and-the-prompt-swamp-2ihc"&gt;https://dev.to/uya0526design/dont-let-claude-haiku-do-the-math-a-two-stage-read-aloud-coach-design-and-the-prompt-swamp-2ihc&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Satellite #3 — The rationale behind the metrics: how I set speed and stagnation (&lt;a href="https://dev.to/uya0526design/i-went-looking-for-the-basis-of-n-characters-per-minute-is-fast-there-wasnt-one-setting-4967"&gt;https://dev.to/uya0526design/i-went-looking-for-the-basis-of-n-characters-per-minute-is-fast-there-wasnt-one-setting-4967&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Satellite #4 — Reflections on AI-collaborative development &amp;amp; a collection of stumbles (&lt;a href="https://dev.to/uya0526design/not-did-you-use-ai-but-are-you-the-one-driving-reflections-on-building-a-real-product-through-464m"&gt;https://dev.to/uya0526design/not-did-you-use-ai-but-are-you-the-one-driving-reflections-on-building-a-real-product-through-464m&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  On to Phase 2
&lt;/h2&gt;

&lt;p&gt;Next, following the Phase 2 plan in the README, I'll tackle &lt;strong&gt;accuracy (script matching via edit distance)&lt;/strong&gt;, &lt;strong&gt;displaying the recognized text on screen&lt;/strong&gt;, and &lt;strong&gt;tempo stability, history, and UI polish.&lt;/strong&gt; Adding "meaningful pause (&lt;em&gt;ma&lt;/em&gt;)" detection to the stagnation rate, and stabilizing Haiku's output (settling the improvement focus in code), remain research items (neither is listed as an individual Phase 2 task yet).&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This was my record of building a "read-aloud coaching app" with AmiVoice and generative AI. Three takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exploiting AmiVoice's timestamps fully&lt;/strong&gt; gave the app a depth that a plain "STT → Claude hand-off" doesn't have.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;two-stage design — code does the math, Haiku only does the wording&lt;/strong&gt; — kept the small model on the job it's best at.&lt;/li&gt;
&lt;li&gt;I kept the &lt;strong&gt;first-hand findings I only learned by verifying&lt;/strong&gt; (the cache that didn't work, the prompt that wasn't obeyed, the AI's wrong attribution) honestly intact.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The detailed development log lives in the repository's &lt;code&gt;LEARNING_LOG&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📦 &lt;a href="https://github.com/uya0526-design/reading-speed-meter" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/reading-speed-meter&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article is part of my public learning journey using AI tools (Claude / Cursor). The design, tech selection, and evaluation-algorithm decisions are mine, and the code is verified with Vitest. I collaborate with AI on the article's structure, outline, and draft prose, and I review and revise every line before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>nextjs</category>
      <category>ai</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building a Weight Tracker CLI in Python — Type Hints, Pure Functions, and pytest</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:56:39 +0000</pubDate>
      <link>https://dev.to/uya0526design/building-a-weight-tracker-cli-in-python-type-hints-pure-functions-and-pytest-3f1k</link>
      <guid>https://dev.to/uya0526design/building-a-weight-tracker-cli-in-python-type-hints-pure-functions-and-pytest-3f1k</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is my eighth article as a Java engineer learning TypeScript and Python from scratch.&lt;/p&gt;

&lt;p&gt;My first seven articles were all small TypeScript CLIs. In the previous one, I built an HTTP client in TypeScript that calls a FastAPI (Python) server I wrote myself — that was my first contact with Python (FastAPI), but only as "the thing the client calls."&lt;/p&gt;

&lt;p&gt;In other words, Python played a supporting role last time. From here, &lt;strong&gt;I start Python properly from "project 1."&lt;/strong&gt; For this first one I go back to the basics: a &lt;strong&gt;weight tracker CLI&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What I focused on this time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python basics (&lt;code&gt;#&lt;/code&gt; comments, &lt;code&gt;def&lt;/code&gt;, snake_case, f-strings)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type hints&lt;/strong&gt; (&lt;code&gt;list[float]&lt;/code&gt;, &lt;code&gt;tuple[float, float, float]&lt;/code&gt;) and how they differ from Java Generics&lt;/li&gt;
&lt;li&gt;Input validation with &lt;code&gt;try / except ValueError&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extracting the calculation logic as a "pure function"&lt;/strong&gt; (separation of concerns, testability)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unit testing with pytest&lt;/strong&gt; (normal, boundary, and error cases)&lt;/li&gt;
&lt;li&gt;The habits an ex-Java engineer slips into — and correcting them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As always, I write honestly about where I got stuck, what I thought through, and what I asked AI for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Where this sits in the series&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the first article in my Python series. It's also a chance to check whether the mindset I picked up in the TypeScript series — "think in types," "separate logic from I/O," "write tests" — still works when the language changes.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  My Learning Style (AI Transparency)
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Learning companions &amp;amp; how this article is written&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;Claude Pro&lt;/strong&gt; (design discussions and Q&amp;amp;A) and &lt;strong&gt;Cursor Pro&lt;/strong&gt; (coding support) as learning companions. Beyond the code, &lt;strong&gt;I also collaborate with AI on writing this article itself.&lt;/strong&gt; Stating the facts plainly here.&lt;/p&gt;

&lt;p&gt;Division of roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tech selection, design, implementation, and code verification&lt;/strong&gt; → me&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Article structure, outline, draft prose, and translation&lt;/strong&gt; → in collaboration with Claude&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All content is checked and revised by me before publishing&lt;/strong&gt; → me&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My guiding principle is "&lt;strong&gt;the thinking is mine, the wording is AI-assisted, and I verify all of it.&lt;/strong&gt;" I write the code myself (I never ask AI to write code for me), I use AI for hints, spec clarification, and bug spotting, and I make sure I understand &lt;em&gt;why&lt;/em&gt; before moving on — and I apply that same stance to the writing process.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this article, I continue to clearly separate "what I implemented myself" from "what I asked AI for."&lt;/p&gt;




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

&lt;p&gt;Enter a menu number, and the CLI records a weight, lists recorded weights, or computes statistics (average, max, min).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--------------------------------
1. Record weight
2. Display the recorded weights
3. Display the weight calculation result
4. Exit
--------------------------------
Enter your choice: 1
Enter your weight(kg): 70.5
--------------------------------
Enter your choice: 3
Weight calculation result:
Average weight: 70.5
Max weight: 70.5
Min weight: 70.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Data is held &lt;strong&gt;in memory&lt;/strong&gt; and resets when you exit (a deliberate simplification for local learning). I'm saving persistence for the next stage.&lt;/p&gt;

&lt;p&gt;📦 Repository: &lt;a href="https://github.com/uya0526-design/weight_tracker_py" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/weight_tracker_py&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  File Structure and Tech Stack
&lt;/h2&gt;

&lt;p&gt;The structure is very simple.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;weight_tracker_py/
├── tests/
│   ├── __init__.py
│   └── test_main.py     # unit tests
├── main.py              # entry point
└── requirements.txt     # dependencies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tech stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python&lt;/strong&gt; 3.12&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pytest&lt;/strong&gt; 9.0.3&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flow from creating a virtual environment (&lt;code&gt;venv&lt;/code&gt;) to running tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv               &lt;span class="c"&gt;# project-local isolated environment&lt;/span&gt;
.&lt;span class="se"&gt;\v&lt;/span&gt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="se"&gt;\S&lt;/span&gt;cripts&lt;span class="se"&gt;\A&lt;/span&gt;ctivate.ps1       &lt;span class="c"&gt;# activate (Windows PowerShell)&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;pytest                &lt;span class="c"&gt;# install the test tool&lt;/span&gt;
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.txt     &lt;span class="c"&gt;# record dependencies&lt;/span&gt;
python main.py                    &lt;span class="c"&gt;# run the app&lt;/span&gt;
pytest                            &lt;span class="c"&gt;# run the tests&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I understood &lt;code&gt;venv&lt;/code&gt; as "per-project isolation," close to a Maven local repository or Node's &lt;code&gt;node_modules&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Implemented Myself
&lt;/h2&gt;

&lt;h3&gt;
  
  
  main.py — The menu loop and function split
&lt;/h3&gt;

&lt;p&gt;I run the menu with &lt;code&gt;while True&lt;/code&gt; + &lt;code&gt;if / elif&lt;/code&gt;, dispatching to a function per choice, and &lt;code&gt;break&lt;/code&gt; on &lt;code&gt;4&lt;/code&gt; to exit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;display_main_menu&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;record_weight&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;display_recorded_weights&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;display_weight_calculation_result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid choice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I understood &lt;code&gt;if __name__ == "__main__":&lt;/code&gt; as a declaration of "this is the program's entry point," close to Java's &lt;code&gt;public static void main(String[] args)&lt;/code&gt;. It doesn't run when imported — only when the file is run directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Input validation — try / except ValueError
&lt;/h3&gt;

&lt;p&gt;The weight is read as a string, so I check whether it can be converted to a number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_weight&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Enter your weight(kg): &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Check if the weight is a number
&lt;/span&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid weight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;recorded_weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first I tried to judge "is this a number?" with &lt;code&gt;isdigit()&lt;/code&gt; and got stuck (more below). Structurally this is almost the same as Java's &lt;code&gt;try-catch&lt;/code&gt;: a string that can't be converted by &lt;code&gt;float()&lt;/code&gt; raises &lt;code&gt;ValueError&lt;/code&gt; (close to Java's &lt;code&gt;NumberFormatException&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Listing — enumerate and an empty-list check
&lt;/h3&gt;

&lt;p&gt;I used &lt;code&gt;enumerate&lt;/code&gt; to list items with numbers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;display_recorded_weights&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Recorded weights: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recorded_weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No weights recorded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recorded_weights&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;enumerate&lt;/code&gt; is a loop that gives you the index and the element at once, letting me write what I used to write as &lt;code&gt;for (int i = 0; i &amp;lt; list.size(); i++)&lt;/code&gt; in Java much more cleanly. I wanted the display to start at 1, so I print &lt;code&gt;index + 1&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Statistics — extracting a pure function (my biggest lesson this time)
&lt;/h3&gt;

&lt;p&gt;This was the design highlight of the project. At first I bundled display and calculation into one function, but I &lt;strong&gt;extracted just the calculation logic as a "pure function that takes arguments and only returns values."&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# display side (reads the global list → hard to test)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;display_weight_calculation_result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Weight calculation result: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recorded_weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No weights recorded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;average_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_weight_calculation_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recorded_weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Average weight: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;average_weight&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Max weight: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_weight&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Min weight: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;min_weight&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# calculation side (takes arguments, only returns values → easy to test)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_weight_calculation_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;average_weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;max_weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;min_weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;average_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_weight&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two points here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Don't read the global variable directly — take it as an argument&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;display_...&lt;/code&gt; side reads the module-level &lt;code&gt;recorded_weights&lt;/code&gt;. That depends on the result of &lt;code&gt;input()&lt;/code&gt;, which makes it hard to test. The &lt;code&gt;calculate_...&lt;/code&gt; side takes &lt;code&gt;weights&lt;/code&gt; as an argument, so a test can pass any list it likes. This is separation of concerns — the same idea I kept coming back to in the TypeScript series, and it worked just as well in Python.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Return multiple values as a tuple&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;return average_weight, max_weight, min_weight&lt;/code&gt; returns three values at once. The caller can unpack them with &lt;code&gt;a, b, c = ...&lt;/code&gt;. In Java, "multiple return values" meant a dedicated class or an array, so writing it with plain syntax felt fresh.&lt;/p&gt;

&lt;p&gt;The return type hint &lt;code&gt;-&amp;gt; tuple[float, float, float]&lt;/code&gt; feels close to Generics like &lt;code&gt;List&amp;lt;Float&amp;gt;&lt;/code&gt; in Java. The big difference is that it's &lt;strong&gt;not enforced at runtime&lt;/strong&gt; (returning a value that doesn't match the type isn't an error), so I understood it as information for the reader and the IDE.&lt;/p&gt;

&lt;h3&gt;
  
  
  tests/test_main.py — normal, boundary, and error cases
&lt;/h3&gt;

&lt;p&gt;Because I'd extracted a pure function, the tests were very straightforward to write.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;calculate_weight_calculation_result&lt;/span&gt;

&lt;span class="c1"&gt;# normal: multiple items
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_calculate_weight_calculation_result&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;70.12345&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;average_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_weight_calculation_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;average_weight&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;80.02&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;max_weight&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;90.0&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;min_weight&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;70.12345&lt;/span&gt;

&lt;span class="c1"&gt;# error: empty list → division by zero
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_calculate_weight_calculation_result_empty_list&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raises&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ZeroDivisionError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;calculate_weight_calculation_result&lt;/span&gt;&lt;span class="p"&gt;([])&lt;/span&gt;

&lt;span class="c1"&gt;# boundary: a single item
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_calculate_weight_calculation_result_one_weight&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;70.12345&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;average_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate_weight_calculation_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;average_weight&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;70.12&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;max_weight&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;70.12345&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;min_weight&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;70.12345&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pytest.raises(ZeroDivisionError)&lt;/code&gt; corresponds to Java's &lt;code&gt;assertThrows(ArithmeticException.class, ...)&lt;/code&gt;. It's interesting that "this should throw an exception" can be written as a test too.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Asked AI For
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;What AI helped with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spec clarification&lt;/td&gt;
&lt;td&gt;Surfacing unclear points: input validation, display format, how to exit, menu design&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comment syntax&lt;/td&gt;
&lt;td&gt;Pointing out that Python uses &lt;code&gt;#&lt;/code&gt;, not &lt;code&gt;//&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validation&lt;/td&gt;
&lt;td&gt;Pointing out that &lt;code&gt;isdigit()&lt;/code&gt; can't handle decimals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversion handling&lt;/td&gt;
&lt;td&gt;Pointing out I was appending the raw string, and that double-converting with &lt;code&gt;float()&lt;/code&gt; was unnecessary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design suggestion&lt;/td&gt;
&lt;td&gt;Suggesting I pass the global variable as an argument, for testability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type hints&lt;/td&gt;
&lt;td&gt;Pointing out a mismatch between &lt;code&gt;-&amp;gt; None&lt;/code&gt; and &lt;code&gt;-&amp;gt; list[float]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;pytest.raises&lt;/code&gt; syntax, and the ideal folder structure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependencies&lt;/td&gt;
&lt;td&gt;Explaining &lt;code&gt;requirements.txt&lt;/code&gt; (direct deps only vs. pinning everything)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;README&lt;/td&gt;
&lt;td&gt;Tidying it up in both Japanese and English&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note: I found and fixed the &lt;strong&gt;wrong test expectation (below) myself.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. I wrote comments with &lt;code&gt;//&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; I wrote a comment with &lt;code&gt;//&lt;/code&gt; and it wouldn't run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; A Java / TypeScript habit. Python comments use &lt;code&gt;#&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Changed them all to &lt;code&gt;#&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; When the language changes, the first differences show up in the tiniest syntax. A small thing, but it reliably reminds me "you're writing a different language now."&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;isdigit()&lt;/code&gt; can't reject decimals
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; I used &lt;code&gt;"70.5".isdigit()&lt;/code&gt; for input validation, and a valid weight got rejected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; &lt;code&gt;isdigit()&lt;/code&gt; returns &lt;code&gt;True&lt;/code&gt; &lt;strong&gt;only when every character is a digit.&lt;/strong&gt; A decimal point &lt;code&gt;.&lt;/code&gt; makes it &lt;code&gt;False&lt;/code&gt;, so a value like &lt;code&gt;70.5&lt;/code&gt; doesn't pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; I dropped the character-type check and switched to trying a &lt;code&gt;float()&lt;/code&gt; conversion, catching failures with &lt;code&gt;try / except ValueError&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# NG: a decimal point makes it False → rejects a valid weight
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="c1"&gt;# OK: try to convert, reject on failure
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid weight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; If you want to know "can this be used as a number?", &lt;strong&gt;actually trying to convert it&lt;/strong&gt; is more reliable than inspecting character types. I learned to pick the check that fits the goal.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. ZeroDivisionError on an empty list
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; Computing the average with zero recorded weights crashed the program.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; In &lt;code&gt;sum(weights) / len(weights)&lt;/code&gt;, &lt;code&gt;len&lt;/code&gt; became &lt;code&gt;0&lt;/code&gt;, causing a division by zero (&lt;code&gt;ZeroDivisionError&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix and design decision:&lt;/strong&gt; I added an early return &lt;code&gt;if len(recorded_weights) == 0&lt;/code&gt; on the display side, returning "No weights recorded" to the user. On the other hand, I deliberately left the &lt;strong&gt;calculation function (&lt;code&gt;calculate_...&lt;/code&gt;) to raise &lt;code&gt;ZeroDivisionError&lt;/code&gt; as-is, and verified that with a test.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; "Where to prevent an error" and "where to let an exception through" are separate decisions. I block early near the UI, while letting the pure function raise naturally and pinning that behavior with a test.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Can't import main.py from tests/
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; &lt;code&gt;from main import ...&lt;/code&gt; in &lt;code&gt;tests/test_main.py&lt;/code&gt; raised &lt;code&gt;ModuleNotFoundError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; &lt;code&gt;tests/&lt;/code&gt; wasn't recognized as a package, so the import couldn't be resolved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Adding &lt;code&gt;tests/__init__.py&lt;/code&gt; (an empty file) resolved it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Folder structure is part of "the spec for making things run." Tracking down the cause myself was a small confidence boost.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. My test expectation was wrong
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; A test failed. The &lt;code&gt;assert&lt;/code&gt; for &lt;code&gt;average_weight&lt;/code&gt; didn't match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; It wasn't the implementation — &lt;strong&gt;my own expected value was wrong.&lt;/strong&gt; I'd written the &lt;code&gt;round()&lt;/code&gt; result from my head without actually computing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; The average of &lt;code&gt;[70.12345, 75, 80, 85, 90]&lt;/code&gt; is &lt;code&gt;400.12345 / 5 = 80.02469&lt;/code&gt;, and &lt;code&gt;round(..., 2)&lt;/code&gt; makes it &lt;code&gt;80.02&lt;/code&gt;. I recomputed it by hand and corrected the expectation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; &lt;strong&gt;"A failing test" doesn't always mean "the implementation is wrong."&lt;/strong&gt; Catching a wrong expectation is also part of a test's value. Experiencing the real-world cycle in miniature — a test fails, then you separate whether the cause is the implementation or the expectation — was my biggest gain this time.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  Python basics (mapped to Java)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Key takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Comments&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;#&lt;/code&gt; (&lt;code&gt;//&lt;/code&gt; is Java / JavaScript)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Function definition&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;def name():&lt;/code&gt; (corresponds to Java's &lt;code&gt;static void&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Naming&lt;/td&gt;
&lt;td&gt;snake_case &lt;code&gt;record_weight&lt;/code&gt; (vs. Java's camelCase)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple return values&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;return a, b, c&lt;/code&gt; returns a tuple (Java needs a dedicated class or array)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;String formatting&lt;/td&gt;
&lt;td&gt;f-strings (&lt;code&gt;f"{variable}"&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Type hints
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You can annotate arguments and return values (&lt;code&gt;list[float]&lt;/code&gt;, &lt;code&gt;tuple[float, float, float]&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Feels close to Java Generics (&lt;code&gt;List&amp;lt;Float&amp;gt;&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;But it's &lt;strong&gt;not enforced at runtime&lt;/strong&gt; — it's information for readability and IDE support&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Exception handling
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;try / except&lt;/code&gt; has the same structure as Java's &lt;code&gt;try-catch&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A string that &lt;code&gt;float()&lt;/code&gt; can't convert raises &lt;code&gt;ValueError&lt;/code&gt; (close to Java's &lt;code&gt;NumberFormatException&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Testability (separation of concerns)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Functions containing &lt;code&gt;input()&lt;/code&gt; or &lt;code&gt;print()&lt;/code&gt; are hard to test&lt;/li&gt;
&lt;li&gt;Extracting the calculation logic as a &lt;strong&gt;pure function that takes arguments and returns values&lt;/strong&gt; makes it testable&lt;/li&gt;
&lt;li&gt;This is exactly the "separation of concerns" that matters in real work&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Unit testing (pytest)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Key takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Position&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pytest&lt;/code&gt; corresponds to Java's &lt;code&gt;JUnit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exception tests&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;with pytest.raises(...)&lt;/code&gt; corresponds to &lt;code&gt;assertThrows(...)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coverage&lt;/td&gt;
&lt;td&gt;Covering normal, boundary, and error cases is the baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value&lt;/td&gt;
&lt;td&gt;When a test fails, it can also reveal a wrong expectation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Project structure
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Putting &lt;code&gt;__init__.py&lt;/code&gt; in &lt;code&gt;tests/&lt;/code&gt; resolves pytest's import&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;requirements.txt&lt;/code&gt; can list "direct deps only" or "pin everything." When reproducibility matters, list everything&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Reflection
&lt;/h2&gt;

&lt;p&gt;I built this the same way as before: write the code myself and have AI review it. As my first Python project, three things stood out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The habit of thinking in types carried straight over:&lt;/strong&gt; The "think in types first" I repeated in the TypeScript series came naturally with Python's type hints. There's the difference that they're not enforced at runtime, but understanding the goal — "write them for the reader and the IDE" — was a real gain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separation of concerns transcends the language:&lt;/strong&gt; Extracting the calculation logic as a pure function made the tests surprisingly easy to write. "Separate I/O from logic" works the same way in TypeScript and in Python.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The experience of a test failing is itself a lesson:&lt;/strong&gt; Recomputing and fixing my own expectation taught me that "a test isn't just a tool for doubting the implementation." Separating whether a failure comes from the implementation or the expectation was the most satisfying part.&lt;/p&gt;

&lt;p&gt;The ex-Java habits (&lt;code&gt;//&lt;/code&gt; comments, &lt;code&gt;isdigit()&lt;/code&gt;) did surface, but I corrected them along with the reasons, so I should be able to leave them behind for the next Python project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This was my record of building a weight tracker CLI in Python, centered on &lt;strong&gt;type hints, pure functions, and pytest&lt;/strong&gt; — my first article in the Python series.&lt;/p&gt;

&lt;p&gt;Continuous progress from the TypeScript series:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Picked up Python basics while staying aware of the differences from Java&lt;/strong&gt; (&lt;code&gt;#&lt;/code&gt;, &lt;code&gt;def&lt;/code&gt;, snake_case, tuple multiple returns)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wrote type hints while understanding that they're not enforced at runtime&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Extracted the calculation logic as a pure function, practicing separation of concerns in Python too&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Covered normal, boundary, and error cases with pytest&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Found and fixed a wrong test expectation myself, experiencing the same cycle as real work&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Next, I want to move into &lt;strong&gt;persistence&lt;/strong&gt; with file saving and JSON, and graduate from this project's "data disappears when you exit" simplification.&lt;/p&gt;

&lt;p&gt;The full learning log is in the repository:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/uya0526-design/weight_tracker_py/blob/main/LEARNING_LOG.md" rel="noopener noreferrer"&gt;weight_tracker_py / LEARNING_LOG.md&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article is part of my public learning journey using AI tools (Claude Pro / Cursor Pro). The thinking and all code are mine; I collaborate with AI on the writing (structure, drafting, translation) and verify every line before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>pytest</category>
      <category>beginners</category>
      <category>testing</category>
    </item>
    <item>
      <title>Building an HTTP Client CLI for Your Own API with TypeScript — FastAPI, POST, and Error Handling</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Sun, 07 Jun 2026 13:32:28 +0000</pubDate>
      <link>https://dev.to/uya0526design/building-an-http-client-cli-for-your-own-api-with-typescript-fastapi-post-and-error-handling-2goo</link>
      <guid>https://dev.to/uya0526design/building-an-http-client-cli-for-your-own-api-with-typescript-fastapi-post-and-error-handling-2goo</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is my seventh article as a Java engineer learning TypeScript from scratch.&lt;/p&gt;

&lt;p&gt;In my previous article, I built a CLI that calls an external weather API (Open-Meteo) with &lt;code&gt;fetch&lt;/code&gt;, and learned about &lt;code&gt;async/await&lt;/code&gt;, response type definitions, and error handling with &lt;code&gt;response.ok&lt;/code&gt;. But that was "read-only (GET)" against "someone else's API." This time I go a step further: &lt;strong&gt;I build the API server myself and then build an HTTP client that calls it.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backend: a REST API server built with &lt;strong&gt;FastAPI (Python)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Client: a &lt;strong&gt;TypeScript&lt;/strong&gt; CLI that calls that API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I focused on this time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Implementing &lt;strong&gt;GET and POST&lt;/strong&gt; with &lt;code&gt;fetch&lt;/code&gt; (previously GET only)&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;Content-Type: application/json&lt;/code&gt;&lt;/strong&gt; header on POST (a 422 error I fixed myself)&lt;/li&gt;
&lt;li&gt;How &lt;strong&gt;where you place try-catch&lt;/strong&gt; changes behavior (continue vs. exit)&lt;/li&gt;
&lt;li&gt;The difference between &lt;strong&gt;&lt;code&gt;return&lt;/code&gt; and &lt;code&gt;throw error&lt;/code&gt;&lt;/strong&gt; (close to Java's &lt;code&gt;throws&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Narrowing the &lt;code&gt;unknown&lt;/code&gt; type with &lt;strong&gt;&lt;code&gt;error instanceof Error&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;FastAPI basics (&lt;strong&gt;Spring-like decorators&lt;/strong&gt;, &lt;code&gt;BaseModel&lt;/code&gt;, &lt;code&gt;HTTPException&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same as always — I write honestly about where I got stuck, what I thought through, and what I asked AI for.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;Scope of this article&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This article centers on the TypeScript client. The Python (FastAPI) side is introduced compactly, as "the thing the client calls."&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  My Learning Style (AI Transparency)
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Learning companions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;Claude Pro&lt;/strong&gt; (design discussions and Q&amp;amp;A) and &lt;strong&gt;Cursor Pro&lt;/strong&gt; (coding support) as learning companions.&lt;/p&gt;

&lt;p&gt;My rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I write all the code myself&lt;/strong&gt; — I never ask AI to write code for me&lt;/li&gt;
&lt;li&gt;AI helps with hints, spec clarification, and bug spotting&lt;/li&gt;
&lt;li&gt;I make sure I understand &lt;em&gt;why&lt;/em&gt; something works before moving on&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this article, I clearly separate "what I implemented myself" from "what I asked AI for."&lt;/p&gt;




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

&lt;p&gt;Pick a menu option in the CLI, and it gets a list of items, gets an item by ID, or adds an item — all against my own API server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Custom&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="nx"&gt;Call&lt;/span&gt; &lt;span class="nx"&gt;CLI&lt;/span&gt; &lt;span class="nx"&gt;tool&lt;/span&gt;
&lt;span class="o"&gt;================================&lt;/span&gt;
&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Get&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;
&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Get&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="nx"&gt;by&lt;/span&gt; &lt;span class="nx"&gt;ID&lt;/span&gt;
&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Add&lt;/span&gt; &lt;span class="nx"&gt;an&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;
&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Exit&lt;/span&gt;
&lt;span class="o"&gt;================================&lt;/span&gt;
&lt;span class="nx"&gt;Choose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apple&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;banana&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cherry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Data is held &lt;strong&gt;in memory&lt;/strong&gt; on the server, so it resets when the server restarts (a deliberate simplification for local learning).&lt;/p&gt;

&lt;p&gt;📦 Repositories&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Client (TypeScript): &lt;a href="https://github.com/uya0526-design/simple_api_ts" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/simple_api_ts&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Server (Python / FastAPI): &lt;a href="https://github.com/uya0526-design/simple_api_py" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/simple_api_py&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  System Overview
&lt;/h2&gt;

&lt;p&gt;This project is split across two repositories. The roles are simply "server and client."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ TypeScript CLI ]  --- HTTP (fetch) ---&amp;gt;  [ FastAPI server ]
  simple_api_ts                              simple_api_py
  - GET  /items                              - holds items in memory
  - GET  /items/{id}                         - accepts GET / POST
  - POST /items                              - returns 404 via HTTPException
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint spec:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/items&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns all items&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/items/{id}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns one item (404 if not found)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/items&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Adds an item (id auto-assigned by the server)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Backend: FastAPI Server (Python)
&lt;/h2&gt;

&lt;p&gt;First I need something to call. This was my first time touching FastAPI, but to an ex-Java engineer's eyes it looked &lt;strong&gt;remarkably close to a Spring Boot controller.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv               &lt;span class="c"&gt;# project-local isolated environment&lt;/span&gt;
.&lt;span class="se"&gt;\v&lt;/span&gt;&lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="se"&gt;\S&lt;/span&gt;cripts&lt;span class="se"&gt;\A&lt;/span&gt;ctivate.ps1       &lt;span class="c"&gt;# activate (Windows PowerShell)&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;fastapi uvicorn       &lt;span class="c"&gt;# install packages&lt;/span&gt;
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.txt     &lt;span class="c"&gt;# record dependencies&lt;/span&gt;
uvicorn main:app &lt;span class="nt"&gt;--reload&lt;/span&gt;         &lt;span class="c"&gt;# start the dev server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I understood &lt;code&gt;venv&lt;/code&gt; as "per-project isolation," close to a Maven local repository or Node's &lt;code&gt;node_modules&lt;/code&gt;, and &lt;code&gt;uvicorn main:app --reload&lt;/code&gt; as the dev server equivalent of TypeScript's &lt;code&gt;ts-node&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementing the endpoints
&lt;/h3&gt;

&lt;p&gt;Routes are defined in &lt;code&gt;main.py&lt;/code&gt;. The decorator feel is just like Spring.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# internal / response use (has id)
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;

&lt;span class="c1"&gt;# request-receiving use (no id — auto-assigned by server)
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ItemCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;

&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apple&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;banana&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cherry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/items/{item_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Item not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ItemCreate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;new_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;new_item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;new_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;new_item&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Splitting RequestDTO and ResponseDTO
&lt;/h3&gt;

&lt;p&gt;The main design decision was &lt;strong&gt;separating the receiving type (&lt;code&gt;ItemCreate&lt;/code&gt;) from the internal/return type (&lt;code&gt;Item&lt;/code&gt;).&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ItemCreate&lt;/code&gt;: only &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;price&lt;/code&gt;. No &lt;code&gt;id&lt;/code&gt; (the client shouldn't send it)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Item&lt;/code&gt;: includes &lt;code&gt;id&lt;/code&gt;. The server assigns it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoiding "id can go in either one" and making each type's responsibility clear — I realized this is the same idea as separating RequestDTO and ResponseDTO in Java.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Java mapping (FastAPI to Spring)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;FastAPI / Python&lt;/th&gt;
&lt;th&gt;Equivalent Java concept&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@app.get("/items")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@GetMapping("/items")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@app.post("/items")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@PostMapping("/items")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;{item_id}&lt;/code&gt; path parameter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@PathVariable&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BaseModel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DTO class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;item: ItemCreate&lt;/code&gt; (typed argument)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@RequestBody&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HTTPException&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ResponseStatusException&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;raise&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;throw&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;len(items)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.size()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uvicorn main:app --reload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ts-node&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/docs&lt;/code&gt; (auto Swagger UI)&lt;/td&gt;
&lt;td&gt;manual setup like SpringFox&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The biggest surprise was that just visiting &lt;code&gt;/docs&lt;/code&gt; auto-generates a Swagger UI — coming from setting that up by hand, that felt like magic.&lt;/p&gt;

&lt;p&gt;That's the server done. Now for the main event: the TypeScript client.&lt;/p&gt;




&lt;h2&gt;
  
  
  Client: TypeScript CLI
&lt;/h2&gt;

&lt;h3&gt;
  
  
  types.ts — Mirror the server's types
&lt;/h3&gt;

&lt;p&gt;The client types mirror the server's DTOs. Dropping &lt;code&gt;id&lt;/code&gt; from &lt;code&gt;ItemCreate&lt;/code&gt; follows the same reasoning.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Item&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ItemCreate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// no id — the server auto-assigns it&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;An interface closes with &lt;code&gt;}&lt;/code&gt;, not &lt;code&gt;};&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first I wrote the end of the &lt;code&gt;interface&lt;/code&gt; as &lt;code&gt;};&lt;/code&gt;. The correct form is &lt;code&gt;}&lt;/code&gt; (watch the difference from a &lt;code&gt;class&lt;/code&gt; or a &lt;code&gt;function&lt;/code&gt; expression). The trailing &lt;code&gt;;&lt;/code&gt; on properties works with or without it, but by TypeScript convention it's common to include it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  api.ts — GET and POST with fetch
&lt;/h3&gt;

&lt;p&gt;Previously I only did GET; this time I add POST too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ItemCreate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./types&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getItems&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/items`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`API error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getItemById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/items/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`API error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputItem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ItemCreate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/items`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputItem&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`API error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I improved from AI feedback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Collect the base URL into a constant&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first I hardcoded &lt;code&gt;"http://localhost:8000"&lt;/code&gt; in all three functions. I pulled it into a single &lt;code&gt;const API_ENDPOINT&lt;/code&gt; to make changes easier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The POST body is fine as &lt;code&gt;JSON.stringify(inputItem)&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first I expanded the fields: &lt;code&gt;JSON.stringify({ name: inputItem.name, price: inputItem.price })&lt;/code&gt;. But since &lt;code&gt;inputItem&lt;/code&gt; is the &lt;code&gt;ItemCreate&lt;/code&gt; type and is guaranteed to hold only &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;price&lt;/code&gt;, &lt;code&gt;JSON.stringify(inputItem)&lt;/code&gt; is enough. &lt;strong&gt;The type makes the code shorter&lt;/strong&gt; — a nice example.&lt;/p&gt;

&lt;h3&gt;
  
  
  index.ts — Interactive menu and input validation
&lt;/h3&gt;

&lt;p&gt;I wrap &lt;code&gt;readline&lt;/code&gt; in a &lt;code&gt;Promise&lt;/code&gt; and run a menu loop with &lt;code&gt;while (true)&lt;/code&gt; + &lt;code&gt;switch&lt;/code&gt; (an application of what I built up in previous projects).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;askQuestion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter ID: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Please enter a number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part here is &lt;strong&gt;where to place try-catch&lt;/strong&gt; (more below). In "get item by ID" (&lt;code&gt;case "2"&lt;/code&gt;), the CLI was exiting entirely when the server returned 404, so I added &lt;code&gt;try-catch&lt;/code&gt; inside the &lt;code&gt;case&lt;/code&gt; to keep the loop going.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;askQuestion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter ID: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Please enter a number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getItemById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;404&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Item not found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// delegate unexpected errors to main().catch()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  api.test.ts — Mocking fetch (success and failure)
&lt;/h3&gt;

&lt;p&gt;Seven tests total. I reuse &lt;code&gt;jest.spyOn(global, "fetch")&lt;/code&gt; from last time. This time, for the failure case, I used &lt;code&gt;mockRejectedValue&lt;/code&gt; to reproduce &lt;code&gt;fetch&lt;/code&gt; itself failing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;throws when the API returns an error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;jest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spyOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mockRejectedValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;network error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getItems&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;afterEach&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;jest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restoreAllMocks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the add (POST) test, I &lt;strong&gt;compared the count (length) before and after&lt;/strong&gt; to confirm exactly one item was added.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; PASS  src/__tests__/api.test.ts

Test Suites: 1 passed, 1 total
Tests:       7 passed, 7 total
Time:        0.248 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Unit vs. integration tests&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tests that complete with mocks are "unit tests"; tests that need the actual FastAPI server running are "integration tests." Right now they share a single describe block, but as things grow, splitting them into &lt;code&gt;unit/&lt;/code&gt; and &lt;code&gt;integration/&lt;/code&gt; folders makes them easier to manage.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What I Asked AI For
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;What AI helped with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spec clarification&lt;/td&gt;
&lt;td&gt;Surfacing missing and unclear requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design suggestion (Python)&lt;/td&gt;
&lt;td&gt;Explaining why to split &lt;code&gt;ItemCreate&lt;/code&gt; and &lt;code&gt;Item&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concept explanation (Python)&lt;/td&gt;
&lt;td&gt;Explaining &lt;code&gt;venv&lt;/code&gt;, &lt;code&gt;pip&lt;/code&gt;, &lt;code&gt;uvicorn&lt;/code&gt;, &lt;code&gt;HTTPException&lt;/code&gt; vs Java/TS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;404 exit bug&lt;/td&gt;
&lt;td&gt;Caught that &lt;code&gt;case "2"&lt;/code&gt; had no try-catch, so errors reached &lt;code&gt;main().catch()&lt;/code&gt; and exited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error message branching&lt;/td&gt;
&lt;td&gt;Suggested &lt;code&gt;error.message.includes("404")&lt;/code&gt; to show "not found"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;return vs throw&lt;/td&gt;
&lt;td&gt;Suggested re-throwing with &lt;code&gt;throw error&lt;/code&gt; instead of &lt;code&gt;return&lt;/code&gt; on unexpected errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base URL collection&lt;/td&gt;
&lt;td&gt;Suggested pulling the three hardcoded URLs into an &lt;code&gt;API_ENDPOINT&lt;/code&gt; constant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Body simplification&lt;/td&gt;
&lt;td&gt;Suggested simplifying to &lt;code&gt;JSON.stringify(inputItem)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;interface syntax&lt;/td&gt;
&lt;td&gt;Caught the trailing &lt;code&gt;};&lt;/code&gt; that should be &lt;code&gt;}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note: I found and fixed the &lt;strong&gt;422 error (below) myself.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A 422 error on POST (the Content-Type header)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; When adding an item (POST), the server returned 422 (Unprocessable Entity).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; My &lt;code&gt;fetch&lt;/code&gt; POST didn't set the &lt;code&gt;Content-Type: application/json&lt;/code&gt; header. Without it, FastAPI can't interpret the request body as JSON and returns 422.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ no header — the body can't be read as JSON → 422&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/items`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputItem&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ declare the Content-Type&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/items`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputItem&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; I reached the cause on my own. A server and a client only agree to "exchange JSON" once that's declared via the header. I never thought about this while writing GET-only code.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. The CLI exits entirely on a 404
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; Entering a non-existent ID exited the CLI with no error message at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The exception thrown by &lt;code&gt;getItemById&lt;/code&gt; wasn't caught inside the &lt;code&gt;switch&lt;/code&gt; and propagated all the way to &lt;code&gt;main().catch()&lt;/code&gt;. Since &lt;code&gt;main().catch()&lt;/code&gt; is the last line of defense that ends the program, the loop couldn't resume and the CLI fell over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ no catch in the case → the exception reaches main().catch() and exits&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getItemById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 404 throws → loop breaks → exit&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added &lt;code&gt;try-catch&lt;/code&gt; inside &lt;code&gt;case "2"&lt;/code&gt;, showing "Item not found" for 404 and continuing the loop (code shown earlier).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; &lt;strong&gt;Where you catch a thrown error decides whether the program continues or exits.&lt;/strong&gt; The same exception leads to a different user experience depending on where you place the catch.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. The difference between &lt;code&gt;return&lt;/code&gt; and &lt;code&gt;throw error&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; On unexpected errors, I first exited main with &lt;code&gt;return&lt;/code&gt;. That quietly ended the program normally, and I couldn't tell what had happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause and fix:&lt;/strong&gt; &lt;code&gt;return&lt;/code&gt; just ends main normally. Logging with &lt;code&gt;console.error(error)&lt;/code&gt; and then re-throwing with &lt;code&gt;throw error&lt;/code&gt; lets it reach &lt;code&gt;main().catch()&lt;/code&gt; and end with &lt;code&gt;process.exit(1)&lt;/code&gt; (abnormal exit).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// normal exit. no error detail remains&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// abnormal exit. delegate to main().catch() → process.exit(1)&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; This felt close to delegating an exception upward with Java's &lt;code&gt;throws&lt;/code&gt;. Choosing deliberately between "swallow it" and "throw it up" matters.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  async/await and fetch (GET / POST)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;POST specifies &lt;code&gt;method&lt;/code&gt;, &lt;code&gt;headers&lt;/code&gt;, and &lt;code&gt;body&lt;/code&gt;. The &lt;code&gt;body&lt;/code&gt; is stringified with &lt;code&gt;JSON.stringify(...)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Sending JSON to FastAPI requires &lt;code&gt;Content-Type: application/json&lt;/code&gt; (without it, 422)&lt;/li&gt;
&lt;li&gt;For both GET and POST, you detect errors yourself with &lt;code&gt;!response.ok&lt;/code&gt; and &lt;code&gt;throw&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Error handling
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Key Takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;try-catch placement&lt;/td&gt;
&lt;td&gt;Where you catch decides whether the program continues or exits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;error instanceof Error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A caught &lt;code&gt;error&lt;/code&gt; is &lt;code&gt;unknown&lt;/code&gt;. Narrow the type before using &lt;code&gt;.message&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;return&lt;/code&gt; vs &lt;code&gt;throw error&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;return&lt;/code&gt; exits normally. &lt;code&gt;throw error&lt;/code&gt; delegates to &lt;code&gt;main().catch()&lt;/code&gt; for an abnormal exit. Close to Java's &lt;code&gt;throws&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Letting types simplify code
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Since the &lt;code&gt;ItemCreate&lt;/code&gt; type is guaranteed to hold only &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;price&lt;/code&gt;, &lt;code&gt;JSON.stringify(inputItem)&lt;/code&gt; sends it as-is. No need to expand the fields by hand&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  FastAPI / Python (first contact)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Key Takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Decorators&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@app.get&lt;/code&gt; / &lt;code&gt;@app.post&lt;/code&gt; correspond to Spring's &lt;code&gt;@GetMapping&lt;/code&gt; / &lt;code&gt;@PostMapping&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BaseModel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Defines request/response data structures. Equivalent to a Java DTO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HTTPException&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An exception with a status code. Equivalent to &lt;code&gt;ResponseStatusException&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RequestDTO/ResponseDTO split&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ItemCreate&lt;/code&gt; (no id) and &lt;code&gt;Item&lt;/code&gt; (with id) separate the responsibilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/docs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Just visiting it auto-generates a Swagger UI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Test design
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Tests that complete with mocks are unit tests; those needing a running server are integration tests&lt;/li&gt;
&lt;li&gt;A POST test can confirm the add by comparing the count (length) before and after&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Reflection
&lt;/h2&gt;

&lt;p&gt;I built this the same way as before: write the code myself and have AI review it. The big difference from last time was that I &lt;strong&gt;built the "other side" of the API myself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three things stood out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A server and a client are connected by a "contract":&lt;/strong&gt; The 422 error came down to declaring "this is JSON" via the &lt;code&gt;Content-Type&lt;/code&gt; header before FastAPI would understand it. Building both sides myself made that boundary agreement tangible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exceptions are a design choice about "where to catch":&lt;/strong&gt; The 404-exit incident showed me that try-catch placement directly shapes the user experience. The &lt;code&gt;return&lt;/code&gt; vs &lt;code&gt;throw error&lt;/code&gt; distinction also clicked, connecting back to my memory of Java's &lt;code&gt;throws&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FastAPI looked like Spring:&lt;/strong&gt; Decorators, DTOs, exception classes, auto-docs. It was rewarding to feel my past Java experience working as a foundation even in a new language.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This was my record of building a self-made FastAPI server and a TypeScript HTTP client CLI that calls it.&lt;/p&gt;

&lt;p&gt;Progress since the previous projects:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Implemented not just GET but POST with &lt;code&gt;fetch&lt;/code&gt; (&lt;code&gt;Content-Type&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Found and fixed the cause of a 422 error myself&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Understood that try-catch placement changes behavior, and made a CLI that doesn't fall over on 404&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Used &lt;code&gt;return&lt;/code&gt; and &lt;code&gt;throw error&lt;/code&gt; deliberately&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Built the server side with FastAPI too, understanding both ends of the API&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Building both ends of an API once gave me both perspectives — "the server as seen from the client" and "the client as seen from the server." Next, I want to build on this setup and move toward more practical data operations and persistence.&lt;/p&gt;

&lt;p&gt;Full learning logs are in each repository:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/uya0526-design/simple_api_ts/blob/main/LEARNING_LOG.md" rel="noopener noreferrer"&gt;simple_api_ts / LEARNING_LOG.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/uya0526-design/simple_api_py/blob/main/LEARNING_LOG.md" rel="noopener noreferrer"&gt;simple_api_py / LEARNING_LOG.md&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article is part of my public learning journey using AI tools (Claude Pro / Cursor Pro). All code is written by me — AI is used for design discussions, bug hints, and spec clarification only.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>python</category>
      <category>fastapi</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building a Weather API CLI with TypeScript — async/await, fetch, and Response Types</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Sat, 06 Jun 2026 01:32:45 +0000</pubDate>
      <link>https://dev.to/uya0526design/building-a-weather-api-cli-with-typescript-asyncawait-fetch-and-response-types-3182</link>
      <guid>https://dev.to/uya0526design/building-a-weather-api-cli-with-typescript-asyncawait-fetch-and-response-types-3182</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;This is my sixth article as a Java engineer learning TypeScript from scratch.&lt;/p&gt;

&lt;p&gt;In my previous article, I built a Quiz CLI and learned about &lt;code&gt;enum&lt;/code&gt;, tuple types, and mocking an entire module with &lt;code&gt;jest.mock()&lt;/code&gt;. This time, I built a &lt;strong&gt;Weather API CLI&lt;/strong&gt; and focused on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Calling an external API asynchronously with &lt;strong&gt;&lt;code&gt;async/await&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;fetch&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Describing an &lt;strong&gt;API response with type definitions&lt;/strong&gt; (&lt;code&gt;type&lt;/code&gt; to model the JSON shape)&lt;/li&gt;
&lt;li&gt;HTTP error handling with &lt;strong&gt;&lt;code&gt;response.ok&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Extracting object key types with &lt;strong&gt;&lt;code&gt;keyof typeof&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Swapping out &lt;code&gt;fetch&lt;/code&gt; with &lt;strong&gt;&lt;code&gt;jest.spyOn(global, "fetch")&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Verifying structure and type at once with &lt;strong&gt;&lt;code&gt;toMatchObject&lt;/code&gt; + &lt;code&gt;expect.any()&lt;/code&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same as always — I write honestly about where I got stuck, what I thought through, and what I asked AI for.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Learning Style (AI Transparency)
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Learning companions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;Claude Pro&lt;/strong&gt; (design discussions and Q&amp;amp;A) and &lt;strong&gt;Cursor Pro&lt;/strong&gt; (coding support) as learning companions.&lt;/p&gt;

&lt;p&gt;My rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I write all the code myself&lt;/strong&gt; — I never ask AI to write code for me&lt;/li&gt;
&lt;li&gt;AI helps with hints, spec clarification, and bug spotting&lt;/li&gt;
&lt;li&gt;I make sure I understand &lt;em&gt;why&lt;/em&gt; something works before moving on&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this article, I clearly separate "what I implemented myself" from "what I asked AI for."&lt;/p&gt;




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

&lt;p&gt;A CLI weather tool. Pick a city, or enter a latitude and longitude, and it shows the current weather, temperature, humidity, and wind speed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Weather API Call CLI
================================================
1. Select a city and run
2. Enter latitude and longitude and run
3. Exit
Choose: 1
1. Sapporo
2. Sendai
3. Tokyo
4. Nagoya
5. Osaka
6. Fukuoka
7. Kagoshima
8. Naha
Choose a city: 3
Tokyo weather: Partly cloudy
Tokyo temperature: 20.4°C
Tokyo humidity: 80%
Tokyo wind speed: 3.1km/h
================================================
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Weather data comes from the free &lt;a href="https://open-meteo.com/" rel="noopener noreferrer"&gt;Open-Meteo&lt;/a&gt; API (no API key required).&lt;/p&gt;

&lt;p&gt;📦 Repository: &lt;a href="https://github.com/uya0526-design/weather_api" rel="noopener noreferrer"&gt;https://github.com/uya0526-design/weather_api&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Project Structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;weather_api/
├── src/
│   ├── index.ts             # Entry point / CLI menu
│   ├── api.ts               # API call logic
│   ├── types.ts             # Type definitions
│   └── __tests__/
│       └── api.test.ts      # Unit tests
├── jest.config.js
├── tsconfig.json
└── package.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same three-layer structure as the previous projects: &lt;code&gt;types.ts&lt;/code&gt; (types) / &lt;code&gt;api.ts&lt;/code&gt; (logic) / &lt;code&gt;index.ts&lt;/code&gt; (UI). New this time: &lt;strong&gt;communicating with an external API&lt;/strong&gt; is the main event.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;TypeScript&lt;/li&gt;
&lt;li&gt;Node.js (&lt;code&gt;readline&lt;/code&gt; and &lt;code&gt;fetch&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Jest + ts-jest (unit testing)&lt;/li&gt;
&lt;li&gt;Open-Meteo API (weather data)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I Implemented Myself
&lt;/h2&gt;

&lt;h3&gt;
  
  
  types.ts — API Response Types
&lt;/h3&gt;

&lt;p&gt;The heart of the type design this time was &lt;strong&gt;how to express an API response as a type&lt;/strong&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  First, define the "city data"
&lt;/h4&gt;

&lt;p&gt;I defined the list of selectable cities and their latitude, longitude, and timezone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// List of selectable cities&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cityList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sapporo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sendai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tokyo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Nagoya&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Osaka&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Fukuoka&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Kagoshima&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Naha&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// Latitude / longitude / timezone per city&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cityCoordinates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tokyo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;35.68123&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;139.76712&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Asia/Tokyo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quietly useful tool here is &lt;strong&gt;&lt;code&gt;keyof typeof&lt;/code&gt;&lt;/strong&gt;. It lets me pull out just the keys of &lt;code&gt;cityCoordinates&lt;/code&gt; (the city names) as a type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CityName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;cityCoordinates&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// becomes a union type: "Sapporo" | "Sendai" | "Tokyo" | ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Java I'd define the cities as an &lt;code&gt;enum&lt;/code&gt;. In TypeScript I can reuse the keys of an object that already exists directly as a type — that felt fresh.&lt;/p&gt;

&lt;h4&gt;
  
  
  Define the API response type
&lt;/h4&gt;

&lt;p&gt;This was the main challenge. I called the API first, checked the actual response JSON, and modeled that shape directly with &lt;code&gt;type&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;WeatherResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;timezone_abbreviation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;current_units&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;temperature_2m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;relative_humidity_2m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;weather_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;wind_speed_10m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nl"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;temperature_2m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;relative_humidity_2m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;weather_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;wind_speed_10m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;A type that didn't match reality&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first I defined &lt;code&gt;temperature_2m&lt;/code&gt; and the other fields inside &lt;code&gt;current&lt;/code&gt; as &lt;code&gt;string&lt;/code&gt;. Looking at the actual API response, they're numbers, so &lt;code&gt;number&lt;/code&gt; is correct — and AI caught this for me (more below). A wrong type definition leads to runtime errors, so I felt firsthand how important it is to check the type against the real response.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Convert weather codes to text
&lt;/h4&gt;

&lt;p&gt;Open-Meteo returns WMO (World Meteorological Organization) weather codes as numbers. I defined a mapping to convert them to readable text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WeatherCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Clear&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mainly clear&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Partly cloudy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cloudy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Fog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Thunderstorm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;📝 &lt;strong&gt;I first tried writing this as an enum&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I originally went for &lt;code&gt;enum&lt;/code&gt;, but I decided on my own that a "code → text" mapping reads more naturally as a &lt;code&gt;const&lt;/code&gt; object than as an &lt;code&gt;enum&lt;/code&gt;. An &lt;code&gt;enum&lt;/code&gt; is "a named set of constants," while an object is "a key-to-value mapping" — the difference in purpose started to feel intuitive.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At this point I had written the keys as the string &lt;code&gt;"0"&lt;/code&gt;, which didn't line up with the numeric &lt;code&gt;weather_code&lt;/code&gt; from the API, so I fixed them to the numeric key &lt;code&gt;0&lt;/code&gt; (also an AI catch).&lt;/p&gt;




&lt;h3&gt;
  
  
  api.ts — Async API Calls with fetch
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;getWeather&lt;/code&gt; function takes a latitude, longitude, and timezone, and calls the API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getWeather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;WeatherResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timezone&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.open-meteo.com/v1/forecast?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`API error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two key points.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Detect HTTP errors with &lt;code&gt;response.ok&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fetch&lt;/code&gt; does not throw on HTTP errors like 404 or 500 — &lt;code&gt;response.ok&lt;/code&gt; just becomes &lt;code&gt;false&lt;/code&gt; (this differs from how some Java HTTP clients behave). So I have to check &lt;code&gt;if (!response.ok)&lt;/code&gt; myself and throw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Branch when &lt;code&gt;timezone&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the flow where you enter latitude/longitude directly, there may be no &lt;code&gt;timezone&lt;/code&gt;, so I branch to leave it out of the query in that case.&lt;/p&gt;




&lt;h3&gt;
  
  
  index.ts — Wrapping readline in a Promise
&lt;/h3&gt;

&lt;p&gt;I adopted the &lt;code&gt;async/await&lt;/code&gt; wrapping I learned in the previous Quiz project, from the start this time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;askQuestion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;question&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets me write &lt;code&gt;await askQuestion(...)&lt;/code&gt; top-to-bottom inside the &lt;code&gt;while (true)&lt;/code&gt; menu loop.&lt;/p&gt;

&lt;p&gt;In the latitude/longitude input flow, I added validation with &lt;code&gt;isNaN&lt;/code&gt; and a range check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;askQuestion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter latitude: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Latitude must be a number between -90 and 90&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weather code is converted to text by looking it up in &lt;code&gt;WeatherCode&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weather_code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weatherName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;WeatherCode&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;WeatherCode&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And rather than hardcoding the units, I pull them from the API response's &lt;code&gt;current_units&lt;/code&gt; (another point improved from an AI catch).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; temperature: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;temperature_2m&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current_units&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;temperature_2m&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; wind speed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wind_speed_10m&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current_units&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wind_speed_10m&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, &lt;code&gt;main().catch()&lt;/code&gt; catches any unexpected errors in one place.&lt;/p&gt;




&lt;h3&gt;
  
  
  api.test.ts — Mocking fetch
&lt;/h3&gt;

&lt;p&gt;The highlight of testing this project was &lt;strong&gt;mocking &lt;code&gt;fetch&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Success case: hit the real API and check
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;can fetch the weather for Tokyo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getWeather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;35.68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;139.76&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Asia/Tokyo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toMatchObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;temperature_2m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;weather_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;toMatchObject&lt;/code&gt; + &lt;code&gt;expect.any(Number)&lt;/code&gt; verifies &lt;strong&gt;not just that a field exists, but that its type matches&lt;/strong&gt; — all at once. I first wrote this with &lt;code&gt;toBeDefined()&lt;/code&gt; only, but that would pass even if the type was off, so I improved it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Failure case: swap fetch for a mock
&lt;/h4&gt;

&lt;p&gt;I can't conveniently produce a broken API for the error path, so I swap &lt;code&gt;fetch&lt;/code&gt; for a mock.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;throws when the API returns an error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;jest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spyOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mockResolvedValue&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getWeather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;35.68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;139.76&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Asia/Tokyo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;API error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;afterEach&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;jest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restoreAllMocks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Last time, &lt;code&gt;fs&lt;/code&gt; couldn't be patched with &lt;code&gt;jest.spyOn&lt;/code&gt; and needed &lt;code&gt;jest.mock()&lt;/code&gt;, but &lt;code&gt;global.fetch&lt;/code&gt; can be swapped with &lt;code&gt;jest.spyOn(global, "fetch")&lt;/code&gt;. The lesson from last time — that which approach works depends on the target's property descriptor (&lt;code&gt;configurable&lt;/code&gt;) — carried over directly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Cleanup belongs in afterEach&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I first wrote &lt;code&gt;jest.restoreAllMocks()&lt;/code&gt; inside the test body, but if the test fails it never reaches that line and the mock stays in place. I moved it into &lt;code&gt;afterEach&lt;/code&gt; so cleanup runs whether the test passes or fails.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Test results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt; PASS  src/__tests__/api.test.ts

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Time:        1.216 s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I Asked AI For
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;What AI helped with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spec clarification&lt;/td&gt;
&lt;td&gt;Identified unclear and missing requirements up front&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type fix&lt;/td&gt;
&lt;td&gt;Caught numeric fields inside &lt;code&gt;current&lt;/code&gt; typed as &lt;code&gt;string&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardcoded units&lt;/td&gt;
&lt;td&gt;Suggested reading units from &lt;code&gt;current_units&lt;/code&gt; instead of fixing them to m/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weather code key mismatch&lt;/td&gt;
&lt;td&gt;Caught string key "0" not matching the numeric &lt;code&gt;weather_code&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unused import removal&lt;/td&gt;
&lt;td&gt;Removed &lt;code&gt;WeatherResponse&lt;/code&gt; that wasn't used in &lt;code&gt;index.ts&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test type verification&lt;/td&gt;
&lt;td&gt;Improved from &lt;code&gt;toBeDefined()&lt;/code&gt; to &lt;code&gt;toMatchObject&lt;/code&gt; + &lt;code&gt;expect.any()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mock cleanup&lt;/td&gt;
&lt;td&gt;Suggested moving &lt;code&gt;restoreAllMocks()&lt;/code&gt; into &lt;code&gt;afterEach&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peer dependency check&lt;/td&gt;
&lt;td&gt;Showed how to use &lt;code&gt;npm info&lt;/code&gt; with &lt;code&gt;peerDependencies&lt;/code&gt; to check a package&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. I typed numeric response fields as string
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; Inside &lt;code&gt;current&lt;/code&gt; on &lt;code&gt;WeatherResponse&lt;/code&gt;, I defined &lt;code&gt;temperature_2m&lt;/code&gt; and others as &lt;code&gt;string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Looking at the actual API response, temperature, humidity, weather code, and wind speed are all &lt;strong&gt;numbers&lt;/strong&gt;. The units (&lt;code&gt;°C&lt;/code&gt;, &lt;code&gt;%&lt;/code&gt;, etc.) live separately in &lt;code&gt;current_units&lt;/code&gt; as strings — I was conflating the two.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ a number, but defined as string&lt;/span&gt;
&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;temperature_2m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ numbers are number&lt;/span&gt;
&lt;span class="nl"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;temperature_2m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Check type definitions against the real API response. Deciding "this feels string-ish" leads to a runtime mismatch — an obvious lesson I learned the hard way.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. I hardcoded the units
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; I wrote the wind speed unit as &lt;code&gt;"m/s"&lt;/code&gt; directly in the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Open-Meteo actually returns wind speed in &lt;strong&gt;&lt;code&gt;km/h&lt;/code&gt;&lt;/strong&gt;. Hardcoding it would make the display a lie. And since the API returns the units in a &lt;code&gt;current_units&lt;/code&gt; field, using that is the right answer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ unit fixed in code&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`wind speed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wind_speed_10m&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;m/s`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ use the unit the API returns&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`wind speed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wind_speed_10m&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current_units&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wind_speed_10m&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Don't hardcode units or config values — pulling them from the data source is more resilient to spec changes. I aligned temperature and humidity to the same approach.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. The weather code keys didn't match because they were strings
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Situation:&lt;/strong&gt; I wrote the &lt;code&gt;WeatherCode&lt;/code&gt; keys as the strings &lt;code&gt;"0"&lt;/code&gt; and &lt;code&gt;"1"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The API's &lt;code&gt;weather_code&lt;/code&gt; comes back as a &lt;strong&gt;number&lt;/strong&gt;. When looking it up with &lt;code&gt;WeatherCode[weather_code]&lt;/code&gt;, the keys need to be defined as numbers or the lookup won't work as intended.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ string keys&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WeatherCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Clear&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ numeric keys (match the numeric weather_code from the API)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WeatherCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Clear&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Match "the key type" to "the type you look it up with." Object keys can look similar but are different things as numbers vs. strings — a good reminder.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. AI's suggestions aren't always correct
&lt;/h3&gt;

&lt;p&gt;This was less a sticking point and more a &lt;strong&gt;turning point in how I learn&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;During development, AI flagged that "the combination of &lt;code&gt;jest ^30&lt;/code&gt; and &lt;code&gt;ts-jest ^29&lt;/code&gt; may have a version mismatch." Rather than taking it at face value, I checked it myself with &lt;code&gt;npm info&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm info ts-jest peerDependencies
&lt;span class="c"&gt;# result: jest: '^29.0.0 || ^30.0.0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ts-jest&lt;/code&gt; supports both jest 29 and 30, so in reality &lt;strong&gt;there was no problem&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; AI suggestions are just hints. When I can confirm a fact with a command, I confirm it myself. I want to build the habit of "verify and then decide" rather than "fix it because AI said so."&lt;/p&gt;

&lt;p&gt;(Side note: &lt;code&gt;npm install&lt;/code&gt; brings each package to its latest version independently, so combined versions can drift. But npm installs them anyway without erroring, which makes it easy to miss — learning that I can check ahead of time by running &lt;code&gt;npm info&lt;/code&gt; against a package's &lt;code&gt;peerDependencies&lt;/code&gt; was a real gain.)&lt;/p&gt;




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

&lt;h3&gt;
  
  
  async/await and fetch
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Writing &lt;code&gt;await fetch(url)&lt;/code&gt; inside an &lt;code&gt;async function&lt;/code&gt; lets you write async code top-to-bottom, sequentially&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fetch&lt;/code&gt; does not throw on HTTP errors — you check &lt;code&gt;response.ok&lt;/code&gt; yourself&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;response.json()&lt;/code&gt; extracts the response body as JSON&lt;/li&gt;
&lt;li&gt;Some Java HTTP clients throw depending on the status code, so this needed a mental shift&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  TypeScript Types
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Key Takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Response type definition&lt;/td&gt;
&lt;td&gt;You can model each JSON field with &lt;code&gt;type&lt;/code&gt;. Choosing &lt;code&gt;number&lt;/code&gt; vs &lt;code&gt;string&lt;/code&gt; matters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;keyof typeof&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Extracts an existing object's keys as a type. I auto-generated a union of city names&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Numeric vs string keys&lt;/td&gt;
&lt;td&gt;Object keys differ as numbers vs strings. Match them to the lookup type&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Jest Mocking
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Key Takeaway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jest.spyOn(global, "fetch")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Swaps the global &lt;code&gt;fetch&lt;/code&gt; for a mock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mockResolvedValue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Creates a mock that returns a Promise (for mocking async functions)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rejects.toThrow()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verifies that an async operation throws&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;toMatchObject&lt;/code&gt; + &lt;code&gt;expect.any()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Verifies structure and type at once — stricter than &lt;code&gt;toBeDefined()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleanup in &lt;code&gt;afterEach&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Mocks reliably reset even when a test fails. Equivalent to Java's &lt;code&gt;@AfterEach&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Design
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't hardcode units and config values&lt;/strong&gt; — pulling them from the data source is more resilient to spec changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove unused imports&lt;/strong&gt; (readability and maintainability)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tests should be written with "what am I verifying" in mind, not just "does it run"&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify AI's suggestions with commands&lt;/strong&gt; — don't take them at face value&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Reflection
&lt;/h2&gt;

&lt;p&gt;I built this with the same style as before: write the code myself and have AI review it.&lt;/p&gt;

&lt;p&gt;Three things stood out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check type definitions against the real response:&lt;/strong&gt; Mixing up &lt;code&gt;string&lt;/code&gt; and &lt;code&gt;number&lt;/code&gt; was a mistake the actual JSON would have prevented. "Look at the real thing, then write the type" is more reliable than "imagine the type first."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A sense for avoiding hardcoding:&lt;/strong&gt; Pulling units from the API made me pause the next time I felt the urge to hardcode a value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use AI, but confirm it yourself in the end:&lt;/strong&gt; Verifying the version-mismatch flag with &lt;code&gt;npm info&lt;/code&gt; myself was my biggest gain this time. AI is convenient, but I want to keep the initiative on fact-checking.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This was my record of building an external API tool (a weather CLI) as a TypeScript beginner.&lt;/p&gt;

&lt;p&gt;Progress since the previous projects:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Called an external API asynchronously with &lt;code&gt;async/await&lt;/code&gt; and &lt;code&gt;fetch&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Modeled the API response structure accurately with &lt;code&gt;type&lt;/code&gt; (choosing &lt;code&gt;number&lt;/code&gt;/&lt;code&gt;string&lt;/code&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Used &lt;code&gt;keyof typeof&lt;/code&gt; to leverage object keys as a type&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mocked &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;jest.spyOn(global, "fetch")&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Built the habit of verifying AI's suggestions myself with &lt;code&gt;npm info&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Next up: a simple HTTP client that calls my own API (FastAPI), going deeper on &lt;code&gt;async/await&lt;/code&gt; and type definitions.&lt;/p&gt;

&lt;p&gt;Full learning log: &lt;a href="https://github.com/uya0526-design/weather_api/blob/main/LEARNING_LOG.md" rel="noopener noreferrer"&gt;LEARNING_LOG.md&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is part of my public learning journey using AI tools (Claude Pro / Cursor Pro). All code is written by me — AI is used for design discussions, bug hints, and spec clarification only.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>jest</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Self-Host Your GitHub Stats Badge on Vercel — Fixing the 'Broken Image' on Your Profile README</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Thu, 04 Jun 2026 12:42:49 +0000</pubDate>
      <link>https://dev.to/uya0526design/self-host-your-github-stats-badge-on-vercel-fixing-the-broken-image-on-your-profile-readme-la0</link>
      <guid>https://dev.to/uya0526design/self-host-your-github-stats-badge-on-vercel-fixing-the-broken-image-on-your-profile-readme-la0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🌐 This is the English version of an article I originally wrote in Japanese on Zenn. The original is linked as the canonical source.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;You've probably seen that "GitHub Stats" card (&lt;code&gt;github-readme-stats&lt;/code&gt;) on a lot of profile READMEs. Ever pasted it in, only to get a broken image? I have.&lt;/p&gt;

&lt;p&gt;I touched on this problem in my previous article, but the fix — self-hosting — has enough steps that I split it into its own post.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 The background (why it breaks in the first place) is covered in the previous article. Worth reading alongside this one.&lt;br&gt;
👉 &lt;a href="https://dev.to/uya0526design/i-built-a-github-profile-readme-the-it-wont-show-up-traps-and-tips-to-stand-out-4pba"&gt;I Built a GitHub Profile README — the 'It Won't Show Up' Traps and Tips to Stand Out&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this article, I'll walk through how to &lt;strong&gt;self-host &lt;code&gt;github-readme-stats&lt;/code&gt; on your own Vercel account so it renders reliably&lt;/strong&gt;, along with the points that are easy to trip over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why self-hosting is needed
&lt;/h2&gt;

&lt;p&gt;The official instance of &lt;code&gt;github-readme-stats&lt;/code&gt; (&lt;code&gt;github-readme-stats.vercel.app&lt;/code&gt;) is shared by many users. Because of that, it easily hits the free-tier API limits, and during busy periods the image won't render (it returns &lt;code&gt;503 SERVICE_UNAVAILABLE&lt;/code&gt; / &lt;code&gt;DEPLOYMENT_PAUSED&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;This isn't a problem on your side — it's a known issue that happens intermittently due to the shared service. &lt;strong&gt;If you self-host, you get your own dedicated instance&lt;/strong&gt;, so you no longer depend on someone else's quota and it renders reliably.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 Assume that badges depending on external services will go down someday. For the important ones, self-hosting is the most reassuring option in the end.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Background terms
&lt;/h2&gt;

&lt;p&gt;Before the steps, here's a quick rundown of the terms that come up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;A cloud deploy / hosting platform for frontend apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hobby plan&lt;/td&gt;
&lt;td&gt;The free plan for personal, non-commercial use. Fine for portfolio purposes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless function&lt;/td&gt;
&lt;td&gt;A function that runs only on request instead of a server running constantly. Vercel uses this to generate the image dynamically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Environment variable&lt;/td&gt;
&lt;td&gt;A config value passed in safely from outside instead of hard-coded. Here it holds the GitHub API token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fork&lt;/td&gt;
&lt;td&gt;Copying someone else's GitHub repository into your own account&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Steps
&lt;/h2&gt;

&lt;p&gt;The overall flow is six steps: register on Vercel → fork the repo → get a token → deploy → check the URL → embed in your README.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Create a Vercel account
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://vercel.com/signup" rel="noopener noreferrer"&gt;https://vercel.com/signup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;"Continue with GitHub"&lt;/strong&gt; to link your GitHub account&lt;/li&gt;
&lt;li&gt;For the plan, choose &lt;strong&gt;"Hobby"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If asked for phone verification, enter it in international format (for Japan, choose &lt;code&gt;+81&lt;/code&gt; and drop the leading &lt;code&gt;0&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Gotcha: the "The user could not be found" error&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even with a correctly entered phone number, you may get this error. It seems to be a known issue on Vercel's side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Submit the form at &lt;a href="https://vercel.com/accountrecovery" rel="noopener noreferrer"&gt;https://vercel.com/accountrecovery&lt;/a&gt; to contact support. Example for the "Additional Details" field:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I tried to sign up via GitHub and was asked to verify my phone number. I entered my mobile number but received the error message "The user could not be found." I have tried re-entering the number multiple times but the issue persists. This is a new account and the phone number has not been used with any other Vercel account.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2. Fork the repository
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://github.com/anuraghazra/github-readme-stats" rel="noopener noreferrer"&gt;https://github.com/anuraghazra/github-readme-stats&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;"Fork"&lt;/strong&gt; button at the top right&lt;/li&gt;
&lt;li&gt;Confirm it's been forked into your own GitHub account&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  3. Get a GitHub Personal Access Token
&lt;/h3&gt;

&lt;p&gt;This is needed so &lt;code&gt;github-readme-stats&lt;/code&gt; can call the GitHub API.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://github.com/settings/tokens" rel="noopener noreferrer"&gt;https://github.com/settings/tokens&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Generate new token" → "Generate new token (classic)"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Settings:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Note&lt;/strong&gt;: anything (e.g. &lt;code&gt;vercel-readme-stats&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiration&lt;/strong&gt;: &lt;code&gt;No expiration&lt;/code&gt; (or any period you like)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Select scopes&lt;/strong&gt;: check &lt;code&gt;public_repo&lt;/code&gt; (use &lt;code&gt;repo&lt;/code&gt; if you also want to count private repositories)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Generate token"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy and save&lt;/strong&gt; the token shown (the string starting with &lt;code&gt;ghp_&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Security note:&lt;/strong&gt; The token is only shown once — once you close this screen it's gone, so be sure to save it. Also, never write the token directly in your code or README; pass it only as a Vercel environment variable in the next step, and don't leak it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4. Deploy to Vercel
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://vercel.com/new" rel="noopener noreferrer"&gt;https://vercel.com/new&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;From the Import Git Repository list, select &lt;code&gt;github-readme-stats&lt;/code&gt; and click &lt;strong&gt;"Import"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Open the &lt;strong&gt;"Environment Variables"&lt;/strong&gt; section and add the following:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;| Key | Value |&lt;br&gt;
   | :--- | :--- |&lt;br&gt;
   | &lt;code&gt;PAT_1&lt;/code&gt; | The token from step 3 (the string starting with &lt;code&gt;ghp_&lt;/code&gt;) |&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Leave the other settings at their defaults and click &lt;strong&gt;"Deploy"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;When the "Congratulations!" screen appears, the deployment is done&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  5. Check the deploy URL
&lt;/h3&gt;

&lt;p&gt;Back on the dashboard, two kinds of URL are shown. &lt;strong&gt;The one you use is the fixed URL&lt;/strong&gt;, so be careful not to mix them up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The URL in the Domains field (e.g. &lt;code&gt;github-readme-stats-xxx-yyy.vercel.app&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;The fixed production URL. Use this one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The long URL in the Deployment field (e.g. &lt;code&gt;github-readme-stats-aabbccddee-xxx.vercel.app&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;A temporary URL that changes per deploy. Don't use it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To verify, open the following in a browser; if the Stats card appears, you're good.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://[your Domains URL]/api?username=[your GitHub username]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. Embed it in your README
&lt;/h3&gt;

&lt;p&gt;Add the following to your profile &lt;code&gt;README.md&lt;/code&gt;, using your own Domains URL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="nv"&gt;GitHub Stats&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://[your&lt;/span&gt; Domains URL]/api?username=[username]&amp;amp;show_icons=true&amp;amp;hide_border=true&amp;amp;count_private=true)
&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="nv"&gt;Top Languages&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://[your&lt;/span&gt; Domains URL]/api/top-langs/?username=[username]&amp;amp;layout=compact&amp;amp;hide_border=true)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the Stats card renders from your own dedicated instance, unaffected by the congestion on the official one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common customization options
&lt;/h2&gt;

&lt;p&gt;You can tweak the look and what's counted via URL query parameters. Here are the ones I use most.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;theme=&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Theme color&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;radical&lt;/code&gt;, &lt;code&gt;dark&lt;/code&gt;, &lt;code&gt;tokyonight&lt;/code&gt;, &lt;code&gt;merko&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;show_icons=true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Show icons&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;hide_border=true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hide the border&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;count_private=true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Also count private repositories&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;hide=&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hide specific items&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;hide=issues,contribs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;layout=compact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compact layout for the language card&lt;/td&gt;
&lt;td&gt;for top-langs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Hobby plan limits and caveats
&lt;/h2&gt;

&lt;p&gt;For this use case (just showing it on your own README), traffic is extremely low, so the Hobby plan is plenty. Still, keep these in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serverless function execution limit: 10 seconds&lt;/strong&gt; (it could rarely time out if the GitHub API is slow to respond, but in practice it's almost never an issue)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bandwidth: 100 GB/month&lt;/strong&gt; (nowhere near reachable for personal use)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No commercial use&lt;/strong&gt; (ads, paid services, etc. require the Pro plan)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;The broken-image problem with &lt;code&gt;github-readme-stats&lt;/code&gt; is usually caused by "the official instance being paused" — not a mistake on your side. Even so, putting something that might go down at any time on the face of your profile is unsettling, so I recommend self-hosting to get your own dedicated instance.&lt;/p&gt;

&lt;p&gt;To recap the steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register on Vercel (Hobby plan is enough)&lt;/li&gt;
&lt;li&gt;Fork &lt;code&gt;github-readme-stats&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Get a GitHub Personal Access Token (&lt;code&gt;public_repo&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Put the token in the &lt;code&gt;PAT_1&lt;/code&gt; environment variable and Deploy&lt;/li&gt;
&lt;li&gt;Note the fixed Domains URL&lt;/li&gt;
&lt;li&gt;Swap your README's image URL for your own Domains URL&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Six steps in total. Once you've done it, it just keeps working reliably.&lt;/p&gt;

&lt;p&gt;You can see my card in action on my profile 👉 &lt;a href="https://github.com/uya0526-design" rel="noopener noreferrer"&gt;github.com/uya0526-design&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/anuraghazra/github-readme-stats" rel="noopener noreferrer"&gt;anuraghazra/github-readme-stats&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vercel.com/pricing" rel="noopener noreferrer"&gt;Vercel pricing (Hobby / Pro)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vercel.com/accountrecovery" rel="noopener noreferrer"&gt;Vercel Account Recovery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/settings/tokens" rel="noopener noreferrer"&gt;GitHub Personal Access Tokens&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>vercel</category>
      <category>github</category>
      <category>readme</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a GitHub Profile README — the 'It Won't Show Up' Traps and Tips to Stand Out</title>
      <dc:creator>Uya</dc:creator>
      <pubDate>Thu, 04 Jun 2026 12:21:49 +0000</pubDate>
      <link>https://dev.to/uya0526design/i-built-a-github-profile-readme-the-it-wont-show-up-traps-and-tips-to-stand-out-4pba</link>
      <guid>https://dev.to/uya0526design/i-built-a-github-profile-readme-the-it-wont-show-up-traps-and-tips-to-stand-out-4pba</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🌐 This is the English version of an article I originally wrote in Japanese on Zenn. The original is linked as the canonical source.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I usually write "I built a small CLI tool in TypeScript / Python" style posts. This time it's a bit different: a write-up about building my &lt;strong&gt;GitHub profile README&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I'm an engineer who spent 15+ years mostly on legacy Java, and I'm now learning TypeScript and Python on my own, publishing what I learn on Zenn and dev.to. Once I had a decent number of learning projects piling up, I decided it was time to build "the face of my GitHub."&lt;/p&gt;

&lt;p&gt;Here's the conclusion up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Writing the README itself is not hard.&lt;/strong&gt; You just line up Markdown and badges.&lt;/li&gt;
&lt;li&gt;But &lt;strong&gt;I kept getting stuck on "it doesn't show up on my profile."&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;And there was a second trap: &lt;strong&gt;the GitHub Stats badge was broken and wouldn't render.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post focuses on the points that are surprisingly easy to miss if you only read the official docs, using my own profile as a concrete example.&lt;/p&gt;

&lt;p&gt;Here's the finished result 👉 &lt;a href="https://github.com/uya0526-design" rel="noopener noreferrer"&gt;github.com/uya0526-design&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 I'm writing this for people building a profile README for the first time. If you've written repository READMEs before but never the profile one, this should be about the right level.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is a profile README, anyway?
&lt;/h2&gt;

&lt;p&gt;GitHub has two kinds of README, and it's easy to confuse them at first. Let me separate them clearly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;th&gt;Where it shows up&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Repository README&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;README.md&lt;/code&gt; inside each repo&lt;/td&gt;
&lt;td&gt;Top of that repository&lt;/td&gt;
&lt;td&gt;Explaining the project and how to use it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile README&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;README.md&lt;/code&gt; inside a repo &lt;strong&gt;named exactly like your username&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Top of your profile page (&lt;code&gt;github.com/username&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Self-introduction and the "face" of your portfolio&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key point: the profile README differs from the repository README you normally write, both in where it lives and what it's for.&lt;/p&gt;

&lt;h3&gt;
  
  
  The essentials of making one
&lt;/h3&gt;

&lt;p&gt;The mechanism is simple. You create a repository whose name &lt;strong&gt;exactly matches your username&lt;/strong&gt;, and put a &lt;code&gt;README.md&lt;/code&gt; at its root.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Username &lt;code&gt;uya0526-design&lt;/code&gt; → repository &lt;code&gt;uya0526-design/uya0526-design&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;GitHub treats this repo as a "special repository"&lt;/li&gt;
&lt;li&gt;Put &lt;code&gt;README.md&lt;/code&gt; at the root&lt;/li&gt;
&lt;li&gt;Make the repository &lt;strong&gt;Public&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In theory it should now show up... and this is where I fell into the swamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the README doesn't show up
&lt;/h2&gt;

&lt;p&gt;I wrote the README, pushed it, and nothing appeared on my profile. I burned a fair amount of time in this state. Let me share the final cause and a checklist for narrowing it down.&lt;/p&gt;

&lt;h3&gt;
  
  
  The real cause: I hadn't pressed the "Share to profile" button
&lt;/h3&gt;

&lt;p&gt;This was the actual culprit in my case.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Just creating the special repository does not always auto-publish it to your profile&lt;/li&gt;
&lt;li&gt;There's a &lt;strong&gt;"Share to profile" button&lt;/strong&gt; on the repository page, and only after pressing it does the README appear on your profile&lt;/li&gt;
&lt;li&gt;The button is subtle, and I completely missed it&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;Takeaway:&lt;/strong&gt; When your README won't show up even after you wrote and pushed it, first look for the "Share to profile" button on the repository page. In my case, this one action got me out of the swamp.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Checklist for when it won't show up
&lt;/h3&gt;

&lt;p&gt;That said, this isn't the only possible cause. There are several easy-to-miss points. Checking them top to bottom is efficient.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does the repository name match your username exactly?&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Watch out for mixing up hyphen &lt;code&gt;-&lt;/code&gt; and underscore &lt;code&gt;_&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Match case as well&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the repository Public?&lt;/strong&gt; (Private repos won't show up)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is &lt;code&gt;README.md&lt;/code&gt; at the root?&lt;/strong&gt; (It won't be picked up from a subfolder)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the README have content?&lt;/strong&gt; (An empty file won't show)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the README at the root of the default branch?&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;The profile README only references the README on the "default branch"&lt;/li&gt;
&lt;li&gt;If you created it locally on &lt;code&gt;master&lt;/code&gt;, pushed, and added &lt;code&gt;main&lt;/code&gt; afterward, the default branch and the branch holding the README can end up out of sync&lt;/li&gt;
&lt;li&gt;Check under &lt;code&gt;Settings → General → Default branch&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did you press "Share to profile"?&lt;/strong&gt; ← my actual cause&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Propagation delay / cache&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;There can be a lag right after pushing. Wait a bit, or hard reload (&lt;code&gt;Ctrl + Shift + R&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Number 5 is an especially easy trap for people who used &lt;code&gt;master&lt;/code&gt; by local habit. Maybe a classic "ex-SE" mistake.&lt;/p&gt;

&lt;h3&gt;
  
  
  A tip for checking: view it while logged out
&lt;/h3&gt;

&lt;p&gt;This one helped in a quiet but real way.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open &lt;code&gt;github.com/username&lt;/code&gt; in &lt;strong&gt;incognito / InPrivate mode&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;When you're logged in, the editing UI takes priority and the appearance can differ, so checking "how others see it" while logged out is the reliable way&lt;/li&gt;
&lt;li&gt;When it's correctly reflected, the README appears large, &lt;strong&gt;above your Pinned repositories&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;In other words, if Pinned is at the very top, the README hasn't been reflected yet&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trap 2: the GitHub Stats badge is broken
&lt;/h2&gt;

&lt;p&gt;You know that "GitHub Stats" card (&lt;code&gt;github-readme-stats&lt;/code&gt;) you often see on profile READMEs? When I pasted it in, the image turned into a broken-image icon.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cause: the official instance was paused
&lt;/h3&gt;

&lt;p&gt;When I opened the image URL directly in a browser, it returned &lt;code&gt;503 SERVICE_UNAVAILABLE&lt;/code&gt; / &lt;code&gt;DEPLOYMENT_PAUSED&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This means the official deployment (on a free Vercel tier) is temporarily paused due to congestion or limits&lt;/li&gt;
&lt;li&gt;Since it's a service shared by many users, this is a known issue that happens intermittently&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It's not a problem on your side&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, my README was fine — the shared service I was riding on happened to be down.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: self-host on your own Vercel
&lt;/h3&gt;

&lt;p&gt;If it's a temporary outage you can just wait, but I wasn't comfortable putting something that might go down again on the "face" of my profile. So I decided to &lt;strong&gt;self-host on my own Vercel account&lt;/strong&gt;. That removes the dependency on someone else's quota.&lt;/p&gt;

&lt;p&gt;Here are just the key steps (the &lt;a href="https://github.com/anuraghazra/github-readme-stats" rel="noopener noreferrer"&gt;official repository&lt;/a&gt; README has the details).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a Vercel account (the free &lt;strong&gt;Hobby plan&lt;/strong&gt; is enough for personal use)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fork&lt;/strong&gt; &lt;a href="https://github.com/anuraghazra/github-readme-stats" rel="noopener noreferrer"&gt;github-readme-stats&lt;/a&gt; to your own account&lt;/li&gt;
&lt;li&gt;Get a GitHub &lt;strong&gt;Personal Access Token&lt;/strong&gt; (classic, with the &lt;code&gt;public_repo&lt;/code&gt; scope)&lt;/li&gt;
&lt;li&gt;Import it into Vercel, set the token as the environment variable &lt;code&gt;PAT_1&lt;/code&gt;, and &lt;strong&gt;Deploy&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Note the &lt;strong&gt;fixed Domains URL&lt;/strong&gt; that gets issued (not the temporary URL that changes per deploy)&lt;/li&gt;
&lt;li&gt;Replace the image URL in your README, swapping the official instance for your own Domains URL&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After swapping, the embed looks like this (replace the URL part with your own):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="nv"&gt;GitHub Stats&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://[your&lt;/span&gt; Domains URL]/api?username=[username]&amp;amp;show_icons=true&amp;amp;hide_border=true&amp;amp;count_private=true)
&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="nv"&gt;Top Languages&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://[your&lt;/span&gt; Domains URL]/api/top-langs/?username=[username]&amp;amp;layout=compact&amp;amp;hide_border=true)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Security note:&lt;/strong&gt; A Personal Access Token is shown only once — copy it before you close the screen. Never write the token directly in your code or README; pass it only as a Vercel environment variable, and don't leak it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;If you get stuck signing up for Vercel (phone verification error):&lt;/strong&gt; When signing up via GitHub, phone verification can fail with "The user could not be found" (this seems to be a known Vercel issue). If that happens, use the form at &lt;a href="https://vercel.com/accountrecovery" rel="noopener noreferrer"&gt;Vercel Account Recovery&lt;/a&gt; to explain the situation and contact support.&lt;/p&gt;

&lt;p&gt;You can see my self-hosted card working in the "GitHub Stats" section near the bottom of my profile.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 This self-hosting procedure turned out to be enough material for its own article. I plan to split out the detailed steps (with screenshots) into a separate post.&lt;/p&gt;

&lt;p&gt;💡 &lt;strong&gt;Lesson:&lt;/strong&gt; Assume that badges depending on external services will go down someday. Self-host the important ones, or place them where it isn't fatal if they break.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What to highlight: from a reskilling ex-engineer's view
&lt;/h2&gt;

&lt;p&gt;Beyond just "making a working README," the real question is &lt;strong&gt;what you communicate to the reader&lt;/strong&gt; (recruiters, collaborators, peers). My position is "15+ years of real experience, but modern tech is still ahead of me," so I focused on showing that honestly and attractively.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I included
&lt;/h3&gt;

&lt;p&gt;In my actual profile, I structured it roughly in this order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Header (one-line catch + badges):&lt;/strong&gt; years of experience, key certifications, and what I'm learning, at a glance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;About Me:&lt;/strong&gt; which domains I've worked in and for how long, plus where I am now (reskilling)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How I Learn:&lt;/strong&gt; less abstract self-PR, more "how I actually move my hands"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certifications:&lt;/strong&gt; a table with dates (high credibility, easy to read)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tech Stack:&lt;/strong&gt; badges &lt;strong&gt;split&lt;/strong&gt; into "professional experience" and "currently learning"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Featured Projects:&lt;/strong&gt; link + tech used + what I practiced, as a set&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing:&lt;/strong&gt; links to Zenn / dev.to&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Stats:&lt;/strong&gt; the self-hosted version above&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Ways of thinking that work for self-promotion
&lt;/h3&gt;

&lt;p&gt;Four things I paid special attention to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Concrete &amp;gt; abstract.&lt;/strong&gt;
"Lots of Java experience" is weaker than "15 years of enterprise Java in telecom, logistics, and finance." Specific industry names add credibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate real experience from what you're learning, honestly.&lt;/strong&gt;
Categorizing accurately without exaggeration actually builds trust. That's why I split my Tech Stack into "Professional experience" and "Currently learning &amp;amp; building."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show how you learn.&lt;/strong&gt;
In my case I wrote about the process itself: "I don't let AI write my code — I implement first, then have AI review," "I design my own tests," "I don't take AI's suggestions at face value — I verify them with actual commands." &lt;strong&gt;Showing the process is itself a statement about your attitude as an engineer.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack up "finished and published" wins, however small.&lt;/strong&gt;
A pile of small projects with tests, a README, and a learning log is plain but strong.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  A small trick for going bilingual
&lt;/h3&gt;

&lt;p&gt;Assuming some global readers, I wrote mainly in English while folding a Japanese version into a collapsible block. That keeps an English-first layout while still being readable for Japanese speakers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;details&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;summary&amp;gt;&lt;/span&gt;Japanese profile (click to expand)&lt;span class="nt"&gt;&amp;lt;/summary&amp;gt;&lt;/span&gt;

(Japanese body goes here)

&lt;span class="nt"&gt;&amp;lt;/details&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;📝 GitHub READMEs support some HTML (like collapsible blocks and centered text), but behavior can vary by environment. Always check the actual rendering after publishing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Wrap-up: a checklist for next time
&lt;/h2&gt;

&lt;p&gt;Finally, a checklist for my future self (and for you, if you read this far).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Created a repository with the &lt;strong&gt;same name&lt;/strong&gt; as your username (exact match)&lt;/li&gt;
&lt;li&gt;[ ] Made it &lt;strong&gt;Public&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Put &lt;code&gt;README.md&lt;/code&gt; at the root of the &lt;strong&gt;default branch&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Pressed the "Share to profile" button&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Opened &lt;code&gt;github.com/username&lt;/code&gt; in incognito mode to verify&lt;/li&gt;
&lt;li&gt;[ ] Checked that external badges (Stats, etc.) render (self-host if not)&lt;/li&gt;
&lt;li&gt;[ ] Wrote "real experience" and "currently learning" separately and accurately&lt;/li&gt;
&lt;li&gt;[ ] Made the content convey your learning process and consistency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A profile README isn't something you make once and forget — it's a place to keep showing your growth. I'll keep updating Featured Projects and "Next up" as projects accumulate.&lt;/p&gt;

&lt;p&gt;Here's the finished profile 👉 &lt;a href="https://github.com/uya0526-design" rel="noopener noreferrer"&gt;github.com/uya0526-design&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading. I hope it helps anyone else stuck in the "it won't show up" swamp.&lt;/p&gt;

</description>
      <category>github</category>
      <category>readme</category>
      <category>vercel</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
