<?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: Daniel Pertu</title>
    <description>The latest articles on DEV Community by Daniel Pertu (@daniel_pertu).</description>
    <link>https://dev.to/daniel_pertu</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%2F3981169%2F83f3f6c9-9d75-47a7-9b7c-c28d13594696.jpg</url>
      <title>DEV Community: Daniel Pertu</title>
      <link>https://dev.to/daniel_pertu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/daniel_pertu"/>
    <language>en</language>
    <item>
      <title>Five things I got wrong shipping v1</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:28:23 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/five-things-i-got-wrong-shipping-v1-3fg7</link>
      <guid>https://dev.to/daniel_pertu/five-things-i-got-wrong-shipping-v1-3fg7</guid>
      <description>&lt;p&gt;I shipped the first version of my assessment-practice platform too eager and slightly wrong in a bunch of ways. None fatal, all instructive. Here's the honest list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. I measured reaction time with &lt;code&gt;Date.now()&lt;/code&gt;.&lt;/strong&gt; Which drifts when the system clock syncs and has coarse resolution — for an app whose whole point is timing, this was embarrassing. Switching to &lt;code&gt;performance.now()&lt;/code&gt; and event timestamps was the single biggest correctness fix I made.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. I built the analytics before I had any data.&lt;/strong&gt; I lovingly engineered a percentile system for a population of… zero. My first users got confidently told they were "in the 50th percentile of 8 people," which is meaningless. I should have shipped raw feedback first and added ranking once real data existed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. My landing page shipped as one bundle.&lt;/strong&gt; Every animation and demo library loaded on first paint, on a page whose job is to convert visitors in three seconds. Code-splitting and lazy-loading below the fold should have been there from day one, not bolted on after a bad Lighthouse score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. I over-modelled the database.&lt;/strong&gt; I stored every trial in its own richly-indexed row forever, planning for scale I didn't have, and made simple queries slower in the process. Storing detail cheaply and summaries fast would have been simpler &lt;em&gt;and&lt;/em&gt; faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. I built features nobody asked for.&lt;/strong&gt; A whole customisation panel, used by approximately no one, while the actual request in every piece of feedback was "more game types." I mistook building for progress.&lt;/p&gt;

&lt;p&gt;The meta-lesson, which I'll relearn again anyway: ship the smallest honest version, put it in front of real users, and let them tell you what to build next. Most of my mistakes were me solving imagined problems before I'd met a single real one.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The v2 of all this is live at CogniPrep, a practice platform for psychometric assessments: &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;https://cogniprep.app&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Post 8 — Getting a JavaScript-heavy product site to actually rank (Next.js App Router)
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Angle: SEO + Next.js. Highly searchable, practical, evergreen.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An interactive, client-heavy site and good SEO feel like opposites — one wants JavaScript, the other wants crawlable HTML. With the Next.js App Router, you can have both, but there are a few things you have to do on purpose. Here's the checklist that moved my pages from invisible to indexed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give every page real metadata.&lt;/strong&gt; The App Router's &lt;code&gt;generateMetadata&lt;/code&gt; lets you produce titles, descriptions, and Open Graph tags per route, on the server, so crawlers see them without running your JS:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateMetadata&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;game&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;getGame&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="nx"&gt;slug&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;title&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;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; practice — CogniPrep`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://cogniprep.app/games/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Set canonical URLs before duplicates bite you.&lt;/strong&gt; Query params, trailing slashes, and A/B variants all spawn duplicate URLs that dilute ranking. An explicit &lt;code&gt;canonical&lt;/code&gt; per page (above) tells search engines which one is the real one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship &lt;code&gt;sitemap.ts&lt;/code&gt; and &lt;code&gt;robots.ts&lt;/code&gt;.&lt;/strong&gt; The App Router generates both from code, so your sitemap stays in sync with your actual routes instead of going stale in a static file. Crawlers find your pages faster and you control what they skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the indexable content server-rendered.&lt;/strong&gt; The interactive demos can be client-only (&lt;code&gt;ssr: false&lt;/code&gt; is fine for a canvas game), but the &lt;em&gt;words&lt;/em&gt; a search engine needs — headings, descriptions, explanations — must be in the server-rendered HTML. Don't hide your actual content inside a client component that only renders after hydration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add JSON-LD structured data.&lt;/strong&gt; A small &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; block describing the page (as a product, article, or FAQ) helps you earn richer search results. It's plain data injected server-side, nothing fancy.&lt;/p&gt;

&lt;p&gt;The mental model that fixed this for me: JavaScript can power the &lt;em&gt;experience&lt;/em&gt;, but the &lt;em&gt;content and metadata&lt;/em&gt; a crawler needs should exist in the HTML before a single line of your JS runs. Split those two concerns, and a heavy interactive site indexes just fine.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Applied all of this to CogniPrep, a practice platform for game-based assessments: &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;https://cogniprep.app&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>javascript</category>
      <category>performance</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Making timed games accessible when the timing is the point</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:27:33 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/making-timed-games-accessible-when-the-timing-is-the-point-3lc2</link>
      <guid>https://dev.to/daniel_pertu/making-timed-games-accessible-when-the-timing-is-the-point-3lc2</guid>
      <description>&lt;p&gt;Most accessibility advice for timers says: let users extend or turn off the time limit (that's literally WCAG's "Timing Adjustable"). But what do you do when the reaction time &lt;em&gt;is the measurement&lt;/em&gt;? You can't just switch off the clock without deleting the thing you're measuring. This tension is real and mostly unaddressed, so I want to share where I landed rather than pretend I solved it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decouple input speed from cognitive speed where you can.&lt;/strong&gt; A lot of "slowness" in a timed task is motor, not mental. So I made sure every game is fully operable by keyboard as well as pointer, kept target sizes generous, and never required fine dragging or double-clicks. That removes a chunk of unfair penalty without touching the measurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never encode meaning in color alone.&lt;/strong&gt; Cognitive games lean hard on color, which quietly excludes colorblind users. I paired every color with a shape or label, and used a palette checked against the common color-vision deficiencies. Same information, more than one channel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&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="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt; &lt;span class="cp"&gt;!important&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;&lt;strong&gt;Respect &lt;code&gt;prefers-reduced-motion&lt;/code&gt;.&lt;/strong&gt; Flashing and rapid movement aren't just uncomfortable — for some users they're disqualifying or dangerous. Honoring the OS setting is one media query and there's no excuse to skip it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offer an extended-time mode, and be honest about what it means.&lt;/strong&gt; For users who need it, I provide a mode with longer windows — clearly flagged as separate, so the timing-based scores aren't silently compared against standard runs. It's a practice tool, so a fair practice environment matters more than a pure number here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Say what you &lt;em&gt;don't&lt;/em&gt; support.&lt;/strong&gt; Some game-based assessment formats are genuinely hard to make fully screen-reader accessible, and pretending otherwise is worse than being clear. I document the current limits so nobody's blindsided.&lt;/p&gt;

&lt;p&gt;The honest takeaway: for timed cognitive tasks, "accessible" isn't a checkbox you complete — it's a set of trade-offs you make deliberately and disclose. Removing motor and sensory penalties is unambiguously right. Adjusting the timing itself is where it gets genuinely hard, and the responsible move is transparency, not a fake solution.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm working through these trade-offs on CogniPrep, a practice platform for game-based assessments: &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;https://cogniprep.app&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>frontend</category>
      <category>ux</category>
    </item>
    <item>
      <title>How I store "unlimited sessions" without my database exploding</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:26:24 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/how-i-store-unlimited-sessions-without-my-database-exploding-2gh8</link>
      <guid>https://dev.to/daniel_pertu/how-i-store-unlimited-sessions-without-my-database-exploding-2gh8</guid>
      <description>&lt;p&gt;I promise users unlimited practice sessions. Every session is a stream of trials, and every trial has timing, correctness, and metadata. Do the math, and a heavy user generates thousands of rows a week. Store all of it naively and both your storage bill and your history queries fall over. Here's how I kept "unlimited" honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate the thing you write a lot from the thing you read a lot.&lt;/strong&gt; Trials are write-heavy and rarely read individually. Session summaries are read constantly (the history page, the percentile). So I split them: an append-only trial log, and a materialised summary row per session computed when the session ends.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;sessions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;id, user_id, game_type, started_at, score, percentile, accuracy, mean_rt&lt;/span&gt;
&lt;span class="na"&gt;trials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;id, session_id, index, stimulus, response, correct, rt_ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The history page never touches the trial table.&lt;/strong&gt; It reads &lt;code&gt;sessions&lt;/code&gt; filtered by &lt;code&gt;user_id&lt;/code&gt;, ordered by &lt;code&gt;started_at&lt;/code&gt;, with an index on &lt;code&gt;(user_id, started_at desc)&lt;/code&gt;. That query stays fast no matter how many trials exist underneath, because it doesn't join to them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold trial data doesn't need to live in hot storage.&lt;/strong&gt; Users rarely drill into an individual trial from six months ago. So raw trials past a certain age get rolled up: I keep the session summary forever, compress the per-trial detail into a single JSON blob on the session (or push it to object storage), and drop the individual rows. History stays complete; the hot table stays small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compute summaries once, not on every read.&lt;/strong&gt; The percentile, mean reaction time, and accuracy are calculated when the session closes and written to the summary row. Recomputing them on every page load would mean rescanning trials constantly — the exact thing the split was meant to avoid.&lt;/p&gt;

&lt;p&gt;The principle generalises to anything with high-volume detail and low-volume summaries: write detail cheaply, read summaries fast, and age the detail out on a schedule. "Unlimited" is a storage strategy, not a promise to keep every byte hot forever.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is the data model behind CogniPrep, a practice platform for psychometric assessments: &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;https://cogniprep.app&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>database</category>
      <category>performance</category>
    </item>
    <item>
      <title>Boolean soup is why your UI has impossible states</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:25:00 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/boolean-soup-is-why-your-ui-has-impossible-states-4ge0</link>
      <guid>https://dev.to/daniel_pertu/boolean-soup-is-why-your-ui-has-impossible-states-4ge0</guid>
      <description>&lt;p&gt;My first version of a mini-game tracked its phase with a pile of booleans: &lt;code&gt;isLoading&lt;/code&gt;, &lt;code&gt;isShowingStimulus&lt;/code&gt;, &lt;code&gt;hasResponded&lt;/code&gt;, &lt;code&gt;isComplete&lt;/code&gt;. It worked until it didn't. A slow network plus a fast click gave me &lt;code&gt;isShowingStimulus &amp;amp;&amp;amp; hasResponded &amp;amp;&amp;amp; isLoading&lt;/code&gt; all true at once — a state that makes no sense but that my code happily rendered. I was writing defensive &lt;code&gt;if&lt;/code&gt; checks to paper over combinations that should never exist.&lt;/p&gt;

&lt;p&gt;The fix was to stop tracking &lt;em&gt;facts about&lt;/em&gt; the state and start naming the state itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A game has phases, so model phases.&lt;/strong&gt; Each mini-game moves through a fixed lifecycle: &lt;code&gt;idle → stimulus → awaiting_response → feedback → complete&lt;/code&gt;. That's a finite state machine. There is exactly one current phase, and only certain transitions are legal.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;machine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;START&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stimulus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;stimulus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;SHOWN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;awaiting_response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;awaiting_response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;RESPOND&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;feedback&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;TIMEOUT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;feedback&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;NEXT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stimulus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;DONE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;complete&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;function&lt;/span&gt; &lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;machine&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ignore illegal events&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;Impossible states become unrepresentable.&lt;/strong&gt; A click that arrives during &lt;code&gt;feedback&lt;/code&gt; sends a &lt;code&gt;RESPOND&lt;/code&gt; event that the machine simply ignores, because &lt;code&gt;feedback&lt;/code&gt; has no &lt;code&gt;RESPOND&lt;/code&gt; transition. No guard clauses, no flag juggling. The bug I was patching couldn't occur.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You get testing and visualization for free.&lt;/strong&gt; Because transitions are data, I can assert "from &lt;code&gt;stimulus&lt;/code&gt;, only &lt;code&gt;SHOWN&lt;/code&gt; does anything" in a unit test, and I can literally draw the graph. When I outgrew the hand-rolled version I moved to XState, but the reducer above carried me a long way and is worth reaching for before adding a dependency.&lt;/p&gt;

&lt;p&gt;The general rule: if you find yourself writing &lt;code&gt;if (a &amp;amp;&amp;amp; !b &amp;amp;&amp;amp; c)&lt;/code&gt; to describe your UI, you probably have one state variable with a handful of legal values, not four independent booleans. Name the states.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I use this pattern across every game in CogniPrep, a practice platform for game-based psychometric assessments: &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;https://cogniprep.app&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>frontend</category>
      <category>programming</category>
      <category>software</category>
    </item>
    <item>
      <title>Keeping a heavy, animated landing page fast in Next.js</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:19:30 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/keeping-a-heavy-animated-landing-page-fast-in-nextjs-m9j</link>
      <guid>https://dev.to/daniel_pertu/keeping-a-heavy-animated-landing-page-fast-in-nextjs-m9j</guid>
      <description>&lt;p&gt;My marketing homepage has interactive game demos, scroll animations, and a lot of moving parts. All of that kills first load if you ship it in one bundle. Here's how I kept it fast without cutting the fun stuff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-render the hero, lazy-load the rest.&lt;/strong&gt; The only thing that needs to be fast is what's above the fold. I keep the hero server-rendered and dependency-light so the Largest Contentful Paint lands early, then defer everything below it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;next/dynamic&lt;/code&gt; for the expensive, client-only pieces.&lt;/strong&gt; The interactive demos are pure client components — no reason to server-render a canvas game:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GameDemo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./GameDemo&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;ssr&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;loading&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DemoSkeleton&lt;/span&gt; &lt;span class="o"&gt;/&amp;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;Only mount a demo when it's about to be seen.&lt;/strong&gt; Dynamic import splits the code, but I don't even want to &lt;em&gt;fetch&lt;/em&gt; that chunk until the user scrolls near it. An Intersection Observer gates the mount:&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setShow&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="nf"&gt;useEffect&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;io&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;IntersectionObserver&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isIntersecting&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;setShow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;rootMargin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;200px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// start loading just before it enters view&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&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="k"&gt;return &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;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&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;&lt;strong&gt;Measure, don't guess.&lt;/strong&gt; &lt;code&gt;@next/bundle-analyzer&lt;/code&gt; showed me a charting lib and an animation lib were most of my initial weight — both trivially deferrable once I stopped importing them at the top level. Pair that with &lt;code&gt;next/image&lt;/code&gt; for the demo thumbnails and &lt;code&gt;next/font&lt;/code&gt; to kill layout-shift from web fonts, and the "everything happens at once" homepage loads like a static one.&lt;/p&gt;

&lt;p&gt;The principle: the browser should pay for a feature only when the user is about to reach it. Split by route &lt;em&gt;and&lt;/em&gt; by viewport.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is the actual homepage for CogniPrep — &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;https://cogniprep.app&lt;/a&gt; if you want to poke at the load behaviour.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>nextjs</category>
      <category>performance</category>
      <category>react</category>
    </item>
    <item>
      <title>Turning a raw score into "you're in the 68th percentile"</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:18:36 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/turning-a-raw-score-into-youre-in-the-68th-percentile-5ij</link>
      <guid>https://dev.to/daniel_pertu/turning-a-raw-score-into-youre-in-the-68th-percentile-5ij</guid>
      <description>&lt;p&gt;A raw score is almost useless on its own. "You scored 74" — good? bad? The number people actually understand is a percentile: &lt;em&gt;where do I sit relative to everyone else.&lt;/em&gt; Building that ranking layer was more interesting than I expected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The naive version breaks fast.&lt;/strong&gt; The textbook definition of a percentile is "the fraction of the population scoring below you." The obvious implementation is: pull every score, sort, find your position. That's fine for a demo and miserable in production — you don't want to load and sort an ever-growing table on every result page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streaming quantiles fix it.&lt;/strong&gt; Instead of storing raw scores forever, I keep a compact summary per game type using a &lt;strong&gt;t-digest&lt;/strong&gt; — a data structure that estimates quantiles from a stream with tight accuracy at the tails (which is exactly where percentile questions get interesting: the 95th matters more than the 50th). New scores update the digest; querying a percentile is cheap and O(1)-ish. You trade a sliver of precision for not hauling around the whole dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cold-start problem is the real trap.&lt;/strong&gt; Early on you don't &lt;em&gt;have&lt;/em&gt; a population. Your first user can't meaningfully be "in the 60th percentile of 12 people." I handled this by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;seeding each game with a modelled reference distribution based on published norms, then&lt;/li&gt;
&lt;li&gt;blending toward the empirical distribution as real data accumulated, and&lt;/li&gt;
&lt;li&gt;hiding the percentile entirely below a minimum-N threshold, showing a raw breakdown instead so I wasn't lying with confidence.
&lt;strong&gt;Interpolate, or your numbers look chunky.&lt;/strong&gt; With discrete data, exact-match percentiles jump in ugly steps. Linear interpolation between the two nearest ranked points smooths it into something that reads as a real, continuous score.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general lesson: "show me where I rank" sounds like one line of SQL and is actually a small system — a summary structure, a norming strategy, and a rule for what to show when you don't know enough yet.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I built this ranking layer for CogniPrep, a practice platform for psychometric assessments. &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;https://cogniprep.app&lt;/a&gt; if you want to see it in action.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>data</category>
      <category>performance</category>
    </item>
    <item>
      <title>Measuring reaction time in the browser is harder than it looks</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:17:30 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/measuring-reaction-time-in-the-browser-is-harder-than-it-looks-4g43</link>
      <guid>https://dev.to/daniel_pertu/measuring-reaction-time-in-the-browser-is-harder-than-it-looks-4g43</guid>
      <description>&lt;p&gt;If your app measures how &lt;em&gt;fast&lt;/em&gt; someone reacts to something, the number you record is only as good as your timing. I learned this the hard way building game-based cognitive assessments, where the reaction time basically &lt;em&gt;is&lt;/em&gt; the measurement — a sloppy 40ms of jitter turns a real signal into noise.&lt;/p&gt;

&lt;p&gt;Here's what actually matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;performance.now()&lt;/code&gt;, not &lt;code&gt;Date.now()&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;Date.now()&lt;/code&gt; is wall-clock time — it can jump around when the system clock syncs, and its resolution is coarse. &lt;code&gt;performance.now()&lt;/code&gt; is monotonic and high-resolution, measured from a fixed time origin. Everything below assumes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't timestamp inside your event handler.&lt;/strong&gt; The intuitive approach is: when the click fires, call &lt;code&gt;performance.now()&lt;/code&gt;. But your handler might run late if the main thread is busy, and you'd be measuring "when my JS got scheduled," not "when the user acted." Every modern input event already carries an accurate &lt;code&gt;event.timeStamp&lt;/code&gt; from the same time origin:&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;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pointerdown&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;e&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;reactionMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeStamp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;stimulusShownAt&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;Timestamp the stimulus at paint, not at state-set.&lt;/strong&gt; When you call &lt;code&gt;setState&lt;/code&gt; to show the target, the pixels don't hit the screen on that line — they hit after the next composite. The closest honest "shown at" is the &lt;code&gt;requestAnimationFrame&lt;/code&gt; callback timestamp for the frame that actually rendered it:&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="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;frameTime&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;stimulusShownAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frameTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// when the browser is about to paint&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;Accept the limits, then design around them.&lt;/strong&gt; You still can't beat the display: a 60Hz screen only updates every ~16.7ms, browsers coarsen timers to mitigate Spectre-style attacks, and background tabs get throttled. So I stopped pretending to measure absolute lab-grade milliseconds and instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;measured &lt;em&gt;relative&lt;/em&gt; differences within a session, where the constant offsets cancel out,&lt;/li&gt;
&lt;li&gt;flagged responses that arrived &lt;em&gt;before&lt;/em&gt; the stimulus (anticipations/guesses) and threw them out,&lt;/li&gt;
&lt;li&gt;normalised against a per-user baseline captured in a warm-up round.
The takeaway that generalises: for any timing-sensitive UI, separate "when did the thing appear" from "when did the user act," get both from the browser's own high-res clock, and be honest about the noise floor you can't remove.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;I'm building CogniPrep, a practice platform for game-based psychometric assessments — this reaction-timing stuff is the guts of it. Link: &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;https://cogniprep.app&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My first post, check it out</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Fri, 12 Jun 2026 11:57:35 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/my-first-post-check-it-out-15a8</link>
      <guid>https://dev.to/daniel_pertu/my-first-post-check-it-out-15a8</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/daniel_pertu/cogniprep-32k2" class="crayons-story__hidden-navigation-link"&gt;CogniPrep&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/daniel_pertu" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3981169%2F83f3f6c9-9d75-47a7-9b7c-c28d13594696.jpg" alt="daniel_pertu profile" class="crayons-avatar__image" width="96" height="96"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/daniel_pertu" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Daniel Pertu
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Daniel Pertu
                
                
              
              &lt;div id="story-author-preview-content-3882333" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/daniel_pertu" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3981169%2F83f3f6c9-9d75-47a7-9b7c-c28d13594696.jpg" class="crayons-avatar__image" alt="" width="96" height="96"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Daniel Pertu&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/daniel_pertu/cogniprep-32k2" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jun 12&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/daniel_pertu/cogniprep-32k2" id="article-link-3882333"&gt;
          CogniPrep
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/career"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;career&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/daniel_pertu/cogniprep-32k2" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;2&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/daniel_pertu/cogniprep-32k2#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            2 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>CogniPrep</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Fri, 12 Jun 2026 11:57:11 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/cogniprep-32k2</link>
      <guid>https://dev.to/daniel_pertu/cogniprep-32k2</guid>
      <description>&lt;p&gt;Job hunting is stressful enough. Then you get to the final stages and hit a psychometric game assessment — a series of cognitive mini-games designed to measure how your brain works under pressure. No practice material, no "study guide", just vibes.&lt;/p&gt;

&lt;p&gt;That's exactly what happened to a friend of mine. They bombed it. Not because they weren't smart — because they'd never seen anything like it before.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://cogniprep.app" rel="noopener noreferrer"&gt;CogniPrep&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;CogniPrep is a practice platform for Arctic Shores psychometric assessments — the game-based cognitive tests used by employers like KPMG, PwC, Deloitte, and a growing number of others. You get access to 14 authentic game simulations, unlimited sessions, and detailed feedback reports with percentile scores so you actually know where you stand.&lt;/p&gt;

&lt;p&gt;The goal isn't to game the system. It's to remove the unfamiliarity. When you've seen the format before, your anxiety drops and your actual ability comes through.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 15&lt;/strong&gt; (App Router) — server components, server actions, the works&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; — auth + Postgres&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS&lt;/strong&gt; — utility-first, as God intended&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt; — deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pretty boring stack, intentionally. The games themselves are the complex part — each one simulates a different cognitive domain (working memory, attention, spatial reasoning, etc.) and has its own scoring logic and session state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interesting problems I ran into
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Game session state
&lt;/h3&gt;

&lt;p&gt;Each game session needs to track timing and responses and produce a score that maps to a percentile relative to a real population baseline. I ended up storing population stats in the DB and running a cron to keep them fresh. Simple but effective.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Feedback that actually means something
&lt;/h3&gt;

&lt;p&gt;Raw scores are useless without context. The feedback system computes percentile rankings so users see not just "you scored 74" but "you scored in the 68th percentile — here's where to focus." That required building out a proper analytics layer per game type.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Landing page performance
&lt;/h3&gt;

&lt;p&gt;The homepage has interactive game demos, animated sections, and a lot going on. I leaned heavily on &lt;code&gt;next/dynamic&lt;/code&gt; with lazy loading to keep the initial bundle lean. The hero renders fast; everything else loads as the user scrolls.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>career</category>
    </item>
  </channel>
</rss>
