<?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: Ahmet Koc</title>
    <description>The latest articles on DEV Community by Ahmet Koc (@ahmet_koc_b5a6d8cd526edd6).</description>
    <link>https://dev.to/ahmet_koc_b5a6d8cd526edd6</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%2F4201587%2Ffaedcd8e-34e5-4d77-91b4-aa7cfaeabc11.jpg</url>
      <title>DEV Community: Ahmet Koc</title>
      <link>https://dev.to/ahmet_koc_b5a6d8cd526edd6</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahmet_koc_b5a6d8cd526edd6"/>
    <language>en</language>
    <item>
      <title>Every question on my IELTS site has to survive a blind solver first</title>
      <dc:creator>Ahmet Koc</dc:creator>
      <pubDate>Fri, 23 Oct 2026 21:39:41 +0000</pubDate>
      <link>https://dev.to/ahmet_koc_b5a6d8cd526edd6/every-question-on-my-ielts-site-has-to-survive-a-blind-solver-first-kml</link>
      <guid>https://dev.to/ahmet_koc_b5a6d8cd526edd6/every-question-on-my-ielts-site-has-to-survive-a-blind-solver-first-kml</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: this post was written with an AI assistant, working from the project's code and docs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://ielts-site-5tq4.onrender.com" rel="noopener noreferrer"&gt;ultraielts&lt;/a&gt;, a free site where people preparing for IELTS take full mock tests in the computer-delivered format. The interface is in Turkish because that's who it's for. The tests are in English, as on the real exam.&lt;/p&gt;

&lt;p&gt;The worst bug a practice test can have isn't a crash. It's a question with two defensible answers, or an answer key that's simply wrong. A student marks NOT GIVEN, the key says FALSE, and from then on they stop trusting every score the site gives them. The tests on the site are original (written for it, no third-party material), and a pipeline helps write them, so I needed a check that is stricter than me rereading the key at 2 a.m.&lt;/p&gt;

&lt;p&gt;So every listening part and reading passage goes through a blind solver before it can be published.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solver sees only what a candidate sees
&lt;/h2&gt;

&lt;p&gt;For each reading passage or listening part, a model gets exactly what a candidate gets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the passage, or for listening, the transcript as "what the candidate hears"&lt;/li&gt;
&lt;li&gt;the questions &lt;strong&gt;without answers&lt;/strong&gt;, produced by the same function that strips the keys before the site sends a test to the browser&lt;/li&gt;
&lt;li&gt;any figures (maps, diagrams, charts) as images&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's told to answer as a band 9 candidate would, using only the text, obeying each group's word limit, and to give a confidence level, a reason of at most 25 words that quotes the decisive words, and a note when something about the question itself makes it impossible or ambiguous.&lt;/p&gt;

&lt;p&gt;Reusing the public "strip the answers" function matters more than it sounds. If the solver got a slightly different view than students do, it would be testing a different exam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scored by the site's own scoring code
&lt;/h2&gt;

&lt;p&gt;The solver's answers aren't compared to the key with string equality. They're scored by the same &lt;code&gt;scoreGroup&lt;/code&gt; function that scores real students. Word limits like "NO MORE THAN TWO WORDS AND/OR A NUMBER", spelling variants, optional words in brackets and TRUE/FALSE/NOT GIVEN aliases are all judged exactly as they would be for a person.&lt;/p&gt;

&lt;p&gt;That removes a whole class of false alarms. "colour" against a key of "color", "7:30" against "7.30", or "forty-five" against "45" isn't a mismatch, because the scorer accepts all of those from a student. When the scorer &lt;em&gt;wouldn't&lt;/em&gt; accept the solver's answer, I want to know before a student finds out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two runs, three kinds of disagreement
&lt;/h2&gt;

&lt;p&gt;The solver runs twice, at two different reasoning-effort settings. Comparing the runs with the key gives three useful signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strong mismatch:&lt;/strong&gt; both runs disagree with the key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak mismatch:&lt;/strong&gt; only one run does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consensus:&lt;/strong&gt; both runs give the &lt;em&gt;same&lt;/em&gt; wrong answer. This is the interesting one. It usually means the key is missing an acceptable variant, or the key is wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A reviewer decides what the mismatch means
&lt;/h2&gt;

&lt;p&gt;A mismatch is a symptom, not a verdict. A second pass, the reviewer, sees the whole unit &lt;em&gt;with&lt;/em&gt; the keys, the deterministic validator's findings, and every mismatch with both runs' answers and reasons. It reports issues with a severity and a suggested fix, and gives each mismatch a verdict: the key is wrong, a variant should be added, the question is ambiguous, the key is right after all, or it's unsure.&lt;/p&gt;

&lt;p&gt;The verdict sets the severity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reviewer verdict&lt;/th&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;key wrong / add a variant / ambiguous&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;key is correct&lt;/td&gt;
&lt;td&gt;warning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unsure or no verdict&lt;/td&gt;
&lt;td&gt;error if the mismatch was strong, warning if weak&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It also looks for things the solver can't: answers that break the word limit, NOT GIVEN vs FALSE confusion, listening answers that come out of order in the recording, and distractors too weak to distract anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repairs are allowed, within strict rules
&lt;/h2&gt;

&lt;p&gt;Errors, and warnings the reviewer marks as auto-fixable, go to a repair step, one question group at a time. The repair has to return the corrected group plus precise edits: exact find-and-replace edits for passages, line edits for transcripts, and a changelog. A repair is &lt;strong&gt;rejected&lt;/strong&gt; if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it changes any question numbers,&lt;/li&gt;
&lt;li&gt;its edits don't apply cleanly to the current text, or&lt;/li&gt;
&lt;li&gt;the result has more validator errors than before.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After a round, only the groups that changed are solved and reviewed again. Anything the checkers no longer report is marked fixed. There are at most three rounds. Anything still open stays open, and the test waits for a human.&lt;/p&gt;

&lt;p&gt;Each issue's id is a fingerprint of where it was found and what it is (source, test, section, issue code, group, questions). The same finding keeps the same id across rounds and across later QA runs, so once I've looked at something and waived it, it stays waived instead of coming back every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The publish gate
&lt;/h2&gt;

&lt;p&gt;Publishing is a separate command that refuses when:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the test would be public but isn't original or rights-cleared (this one can't be waived),&lt;/li&gt;
&lt;li&gt;the validator still finds errors, or&lt;/li&gt;
&lt;li&gt;the test's QA job has open error-level issues, unless I waive them by code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rights rule is the one I care about most. Test-prep sites are full of "real past papers" of doubtful origin. Here, anything imported from a book stays private, as a share link for one's own students, and only original or rights-cleared tests can be public.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't let the checker see more than the user does.&lt;/strong&gt; A solver that sees the key, or a different rendering of the question, is checking something else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score with production code.&lt;/strong&gt; If your checker has its own notion of a correct answer, you end up with two notions of correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate symptoms from verdicts.&lt;/strong&gt; The solver only finds disagreements; a different pass with more context decides what each one means.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constrain the fixer harder than the finder.&lt;/strong&gt; Most of the safety comes from what a repair is &lt;em&gt;not&lt;/em&gt; allowed to do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site is live, free and needs no signup: &lt;a href="https://ielts-site-5tq4.onrender.com" rel="noopener noreferrer"&gt;ultraielts&lt;/a&gt;. The interface is Turkish, but the tests are English. If you write exam questions of any kind, I'd like to hear how you check them.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>typescript</category>
      <category>testing</category>
    </item>
    <item>
      <title>Building a 29-skill map from an append-only answer log</title>
      <dc:creator>Ahmet Koc</dc:creator>
      <pubDate>Fri, 23 Oct 2026 07:34:14 +0000</pubDate>
      <link>https://dev.to/ahmet_koc_b5a6d8cd526edd6/building-a-29-skill-map-from-an-append-only-answer-log-1o1</link>
      <guid>https://dev.to/ahmet_koc_b5a6d8cd526edd6/building-a-29-skill-map-from-an-append-only-answer-log-1o1</guid>
      <description>&lt;p&gt;I build &lt;a href="https://satultra.com" rel="noopener noreferrer"&gt;satultra&lt;/a&gt;, a digital SAT practice site. After a practice test, most tools lead with one number, and a 1290 says how you did, not what to study next.&lt;/p&gt;

&lt;p&gt;The digital SAT is built around 29 skills: College Board's question bank lists 10 in Reading and Writing and 19 in Math. If every answer is filed under one of them, the results page can show where the points went. Here's how that works in a React and Firestore app: one taxonomy file, an append-only event log, pure reducers and a planner.&lt;/p&gt;

&lt;h2&gt;
  
  
  One taxonomy file, one canonical id
&lt;/h2&gt;

&lt;p&gt;Over several imports, the same skill showed up as "Central Ideas and Details", "central ideas &amp;amp; details" or just &lt;code&gt;1&lt;/code&gt;. So the first rule was one source of truth, &lt;code&gt;subcategoryTaxonomy.json&lt;/code&gt;: eight domains and 29 skills, each with a kebab-case id, a domain and its aliases.&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"central-ideas-details"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"numericId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Central Ideas and Details"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"information-ideas"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aliases"&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="s2"&gt;"central ideas and details"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"central ideas &amp;amp; details"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kebab id is canonical everywhere: Firestore document ids, events, the planner. &lt;code&gt;numericId&lt;/code&gt; survives only for older hand-authored questions, and &lt;code&gt;toCanonicalSubcategoryId()&lt;/code&gt; maps any alias or legacy id to the canonical one. The API keeps a copy of the file, and a verify script keeps the two in sync.&lt;/p&gt;

&lt;p&gt;If I did it again, I'd fix the canonical id before the second import. Our question documents still carry three versions of the field: &lt;code&gt;subcategory&lt;/code&gt;, &lt;code&gt;subCategory&lt;/code&gt; and &lt;code&gt;subcategoryId&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log every answer as an event
&lt;/h2&gt;

&lt;p&gt;Every answer that counts, from a quiz, a drill or a full practice test, goes into an &lt;code&gt;activityEvents&lt;/code&gt; collection that clients can append to but never edit. Quiz and test completions, lesson views and flashcard sessions go there too. When a student deletes a test result, the server removes its events and replays the rest.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a question_attempt event's payload&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;smartquiz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// or exam, drill, lesson_quiz, word_quiz&lt;/span&gt;
  &lt;span class="nx"&gt;questionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;q_8f2c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;subcategoryId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;words-in-context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;correct&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="nx"&gt;timeSpentMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;quizId&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 per-skill numbers are derived from that log. Because the log is the source of truth, the derived state can be rebuilt at any time by replaying events, and a backfill script does exactly that for older history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not every finished sitting is evidence
&lt;/h2&gt;

&lt;p&gt;A test module left blank, more than half unanswered, or clicked through in under two minutes is judged once, when the result is saved. Its answers never become skill evidence, though the sitting itself is still logged. A five-question quiz "done" in under a minute gets the same treatment.&lt;/p&gt;

&lt;p&gt;Blank questions inside a real attempt count as wrong for the test score, but they aren't attempts, so they don't drag down skill accuracy. Before these rules, skipped modules produced skill cards like "Linear Functions: 0% over 17 questions".&lt;/p&gt;

&lt;h2&gt;
  
  
  Pure reducers turn the log into skill state
&lt;/h2&gt;

&lt;p&gt;Derived state lives at &lt;code&gt;users/{uid}/skillState/{skillId}&lt;/code&gt;. It's computed by pure functions: no I/O and no &lt;code&gt;Date.now()&lt;/code&gt;, since timestamps come in on the event. The same module runs in the browser for live updates and in Node for replays, so both paths apply the same logic, and a replay can rebuild whatever a failed live update left behind.&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reduceSkillState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&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;s&lt;/span&gt; &lt;span class="o"&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;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bySource&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="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bySource&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempts&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correct&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accuracy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastResults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastResults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correct&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;LAST_N&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// last 20&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accuracyLast10&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;accuracyOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollingAccuracy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollingAccuracy&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correct&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="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="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;EWMA_ALPHA&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correct&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="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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;EWMA_ALPHA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rollingAccuracy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeTrend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastResults&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastPracticedTs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ts&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="c1"&gt;// ...per-source counts and average time per question&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&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;Lifetime accuracy is kept, but the weakest-skills list and the score estimate use &lt;code&gt;accuracyLast10&lt;/code&gt;. A student who was weak at Boundaries a month ago and has since fixed it shouldn't keep seeing it at the top of the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is a skill improving or slipping?
&lt;/h2&gt;

&lt;p&gt;The trend compares the last 10 answers in a skill with up to 10 before them:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeTrend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastResults&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;lastResults&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;lastResults&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;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insufficient&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;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lastResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prior&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lastResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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;lastResults&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;10&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;prior&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;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insufficient&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;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;accuracyOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;accuracyOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prior&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;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;TREND_DELTA_PTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;improving&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 15 points&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;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;TREND_DELTA_PTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;declining&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stable&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;So a trend needs at least 15 answers and a swing of 15 points. Anything smaller is treated as noise at this sample size.&lt;/p&gt;

&lt;p&gt;A declining skill is labeled "slipping". A skill with five or more attempts and nothing in the last 8 days is labeled "fading". Neither label changes the numbers: idle time doesn't decay accuracy, it only flags the skill for a short review.&lt;/p&gt;

&lt;p&gt;Below the skill level, individual concepts get the same treatment. A concept the student struggled with and then answered right three times in a row is marked recovered, and two misses in a row after that raise a regression flag. Those flags rank first in a four-item list of what to work on next, followed by repeated misses, slipping skills, fading skills and the lowest recent accuracy. The built-in coach reads the same derived state.&lt;/p&gt;

&lt;h2&gt;
  
  
  From skill accuracy to a study plan
&lt;/h2&gt;

&lt;p&gt;The planner, &lt;code&gt;generatePlan()&lt;/code&gt;, is deliberately simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sort the 29 skills weakest first by recent accuracy. Skills with no attempts count as 50%, so they sit between real weaknesses and real strengths.&lt;/li&gt;
&lt;li&gt;Give each skill a 20-minute lesson, skipped if already done, and a 15-minute, 10-question practice set.&lt;/li&gt;
&lt;li&gt;Lay that queue across the days from tomorrow to the day before the test, skipping rest days, up to the student's minutes per day.&lt;/li&gt;
&lt;li&gt;Keep the last two days for reviews of the weakest domains.&lt;/li&gt;
&lt;li&gt;If it doesn't all fit, keep the weakest skills and set &lt;code&gt;truncated&lt;/code&gt;, so the page can say so.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When a student finishes a lesson or quiz elsewhere in the app, the plan reconciles on the next load and ticks off the matching task. Each quiz can credit only one task.&lt;/p&gt;

&lt;h2&gt;
  
  
  A prerendering bug worth knowing about
&lt;/h2&gt;

&lt;p&gt;The public pages are part of the same Vite SPA, served by one Express app on Render. For crawlers and a faster first paint, a build step opens 13 public routes in headless Chrome with Puppeteer and saves the rendered HTML. Express serves those snapshots, and the SPA boots on top.&lt;/p&gt;

&lt;p&gt;The snapshot also captured scripts that SDKs had injected at runtime. Serialized back to HTML, an injected &lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt; loses the implicit async it had and becomes a parser-blocking script at the end of &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. PostHog's config script did exactly that: the parser stalled on a round trip to a new origin, the async module entry ran before &lt;code&gt;#root&lt;/code&gt; existed, and &lt;code&gt;createRoot(null)&lt;/code&gt; threw React error #299. On fast connections, the app never mounted.&lt;/p&gt;

&lt;p&gt;The fix has two parts. Before saving a snapshot, drop every script that isn't in the built &lt;code&gt;index.html&lt;/code&gt; template; the SDKs inject themselves again when the app boots. Then strip &lt;code&gt;async&lt;/code&gt; from the module entry so it stays deferred. As a backstop, the entry itself waits for &lt;code&gt;DOMContentLoaded&lt;/code&gt; before mounting.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;React 18 and React Router 6 with lazy-loaded pages, built with Vite&lt;/li&gt;
&lt;li&gt;Firebase Auth and Firestore, with firebase-admin on the server&lt;/li&gt;
&lt;li&gt;One Node/Express app on Render serving both &lt;code&gt;/api&lt;/code&gt; and the built site&lt;/li&gt;
&lt;li&gt;Stripe Checkout for one-time passes, no subscriptions&lt;/li&gt;
&lt;li&gt;Puppeteer for prerendering&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;satultra is free to start, with the diagnostic, three full-length tests, the question bank and the planner, at &lt;a href="https://satultra.com" rel="noopener noreferrer"&gt;satultra.com&lt;/a&gt;. Lessons come with a paid pass. If you've built progress tracking for a learning app, I'd like to hear how you handled recent versus lifetime accuracy, and whether you decay old answers.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>react</category>
      <category>firebase</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
