<?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: yulingg zhang</title>
    <description>The latest articles on DEV Community by yulingg zhang (@yulingg_zhang_dfbce7a345a).</description>
    <link>https://dev.to/yulingg_zhang_dfbce7a345a</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%2F4112509%2Ff1b8ba84-b1bf-428a-8f3d-fe385fd89606.png</url>
      <title>DEV Community: yulingg zhang</title>
      <link>https://dev.to/yulingg_zhang_dfbce7a345a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yulingg_zhang_dfbce7a345a"/>
    <language>en</language>
    <item>
      <title>Pixel-to-inch conversion is a units problem, not a screen-resolution problem</title>
      <dc:creator>yulingg zhang</dc:creator>
      <pubDate>Fri, 25 Sep 2026 07:07:35 +0000</pubDate>
      <link>https://dev.to/yulingg_zhang_dfbce7a345a/pixel-to-inch-conversion-is-a-units-problem-not-a-screen-resolution-problem-2ogg</link>
      <guid>https://dev.to/yulingg_zhang_dfbce7a345a/pixel-to-inch-conversion-is-a-units-problem-not-a-screen-resolution-problem-2ogg</guid>
      <description>&lt;p&gt;A pixel count alone cannot tell you the physical size of a printed image. You need a density assumption. That sounds obvious, but it is easy to lose that assumption when a calculator presents an answer with two decimal places.&lt;/p&gt;

&lt;p&gt;Disclosure: I maintain &lt;a href="https://pixeltoinch.com/" rel="noopener noreferrer"&gt;PixelToInch&lt;/a&gt;, a browser-based conversion tool. This article was prepared with AI assistance and describes the arithmetic and the limits of the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the density part of the input
&lt;/h2&gt;

&lt;p&gt;For an image edge, the relationship is:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inches = pixels / pixelsPerInch&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;An 1800-pixel edge at 300 PPI is 6 inches. The same 1800 pixels at 150 PPI cover 12 inches. Nothing about the source image changed: we changed the proposed placement density.&lt;/p&gt;

&lt;p&gt;A useful conversion result should therefore read “6 inches at 300 PPI”, rather than just “6 inches”. If someone copies the number into a design brief, the assumption needs to travel with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate the denominator before formatting
&lt;/h2&gt;

&lt;p&gt;Here is a small JavaScript example, independent of any framework:&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;function&lt;/span&gt; &lt;span class="nf"&gt;pixelsToInches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pixels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ppi&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pixels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;pixels&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pixels must be a positive finite number&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="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ppi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;ppi&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PPI must be a positive finite number&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;return&lt;/span&gt; &lt;span class="nx"&gt;pixels&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;ppi&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;inches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pixelsToInches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1800&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="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;inches&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the form boundary, reject empty text before numeric conversion: JavaScript converts an empty string to zero. Keep the numeric result at full precision for subsequent calculations and round only for presentation. Multiplying the unrounded inch value by 25.4 gives millimetres; multiplying by 2.54 gives centimetres.&lt;/p&gt;

&lt;p&gt;This function intentionally requires a PPI value. A user interface can provide a clearly labelled default, but the function should not quietly infer density from a device's screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep three different concepts separate
&lt;/h2&gt;

&lt;p&gt;Image dimensions, such as 3000 × 2000 pixels, describe the raster. PPI describes the proposed relationship between those pixels and physical inches. A printer's advertised DPI describes its output dots and is not a direct substitute for image PPI.&lt;/p&gt;

&lt;p&gt;CSS also has its own reference-pixel model. A web preview is not proof that a physical print will have the displayed size. Browser zoom, device scaling and print-dialog options can each change what someone sees or receives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cropping is a separate operation
&lt;/h2&gt;

&lt;p&gt;A 3000 × 2000 image has a 3:2 aspect ratio. A 10 × 8 inch frame is 5:4. Dividing both image edges by a single PPI does not fix that mismatch.&lt;/p&gt;

&lt;p&gt;One option is to crop the image to 2500 × 2000 pixels. At 250 PPI, that gives 10 × 8 inches. Another is to preserve the image and accept borders. Stretching to fit changes the subject's proportions. The calculator can explain these choices, but it cannot choose the right composition for someone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test relationships, not just screenshots
&lt;/h2&gt;

&lt;p&gt;Check that doubling PPI halves the inch result, and that converting back recovers the original pixel count within a reasonable floating-point tolerance. Also check zero, negative, blank and non-finite inputs before they reach formatting code.&lt;/p&gt;

&lt;p&gt;Finally, label the scope honestly. Conversion arithmetic does not establish print quality: source sharpness, resampling, paper, viewing distance and the print provider still matter. A precise number is useful only when its assumptions are equally visible.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>softwaredevelopment</category>
      <category>tools</category>
    </item>
    <item>
      <title>An element entering the viewport is not the same as a survey impression</title>
      <dc:creator>yulingg zhang</dc:creator>
      <pubDate>Fri, 25 Sep 2026 03:58:19 +0000</pubDate>
      <link>https://dev.to/yulingg_zhang_dfbce7a345a/an-element-entering-the-viewport-is-not-the-same-as-a-survey-impression-l22</link>
      <guid>https://dev.to/yulingg_zhang_dfbce7a345a/an-element-entering-the-viewport-is-not-the-same-as-a-survey-impression-l22</guid>
      <description>&lt;p&gt;An optional question below a useful tool can help distinguish different visitor intents. But its denominator needs care. A page view does not prove that someone reached the question, and an unanswered question does not reveal their preference.&lt;/p&gt;

&lt;p&gt;I maintain RetroPrompt, where a page now asks whether visitors want a still portrait, a video template, or are simply exploring. This article was prepared with AI assistance and describes the measurement implementation, not a finding about visitor preferences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define exposure before reporting an answer rate
&lt;/h2&gt;

&lt;p&gt;The question sits after the copy and generator controls so that answering it is not a prerequisite for using the page. That means some visitors may finish their task without ever seeing the question.&lt;/p&gt;

&lt;p&gt;For this implementation, exposure means that at least half of the panel intersects the viewport. The number is an operational definition, not proof of attention or reading. Sticky headers, overlays, background tabs and fast scrolling can still make the signal imperfect.&lt;/p&gt;

&lt;p&gt;There is an easy IntersectionObserver trap: supplying &lt;code&gt;threshold: 0.5&lt;/code&gt; does not make every callback entry a half-visible impression. Check the entry's actual intersection ratio as well as &lt;code&gt;isIntersecting&lt;/code&gt;.&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;let&lt;/span&gt; &lt;span class="nx"&gt;recorded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;observer&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;entries&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;visibleEnough&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;entry&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="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intersectionRatio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&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;visibleEnough&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;recorded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;recorded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;recordExposure&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;observer&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;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;observer&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;questionPanel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sketch assumes the panel can fit sufficiently inside the viewport. A very tall panel needs a different visibility target. It also leaves telemetry delivery, consent and browser support handling to the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep unanswered sessions visible
&lt;/h2&gt;

&lt;p&gt;Report question-seen sessions, answered sessions, first-choice counts and unanswered sessions separately. A session that never answered is not a vote for the default option. An explicit “just exploring” answer is different from silence.&lt;/p&gt;

&lt;p&gt;Allow visitors to change the visible selection, but decide which behavior your report measures. If the question is about the initial reason for arriving, the first expressed choice is one reasonable convention. If it concerns the final preferred output, that convention may be wrong. State the rule in the report instead of changing it after looking at the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the boundary, not only the happy path
&lt;/h2&gt;

&lt;p&gt;Test a panel with only a small strip visible, then scroll until the threshold is met. Exposure should appear only after the second step. Test repeated observer callbacks, repeated clicks, a changed answer and a reload within the same session.&lt;/p&gt;

&lt;p&gt;Also exclude your own test session from the audience report. A tiny site's survey can otherwise be dominated by the developer verifying the buttons. In the browser, a click can record exposure as a fallback for an interaction that arrives before the observer callback; the reporting layer must still deduplicate the session.&lt;/p&gt;

&lt;p&gt;Finally, self-selected survey answers are not the same as demand or willingness to pay. Compare them with actual useful actions, and report the uncertainty when the sample is small.&lt;/p&gt;

&lt;p&gt;The example interface is on &lt;a href="https://retroprompt.site/osm-template-ai/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;RetroPrompt's OSM guide&lt;/a&gt;. I maintain the site. It currently offers static portrait generation and prompts, not video-template export.&lt;/p&gt;

</description>
      <category>javascript</category>
    </item>
    <item>
      <title>Keep useful referral attribution without saving raw referrer URLs</title>
      <dc:creator>yulingg zhang</dc:creator>
      <pubDate>Fri, 25 Sep 2026 03:42:42 +0000</pubDate>
      <link>https://dev.to/yulingg_zhang_dfbce7a345a/keep-useful-referral-attribution-without-saving-raw-referrer-urls-14ln</link>
      <guid>https://dev.to/yulingg_zhang_dfbce7a345a/keep-useful-referral-attribution-without-saving-raw-referrer-urls-14ln</guid>
      <description>&lt;p&gt;A small web tool usually needs to answer a modest question: which sources lead to useful actions? Keeping every incoming URL is not necessary to answer it. A fixed source category can connect a landing to copying a prompt or opening a generator without retaining query strings from another site.&lt;/p&gt;

&lt;p&gt;I maintain RetroPrompt, and this article describes a narrow implementation recently added to that project. It was prepared with AI assistance. It is an implementation note, not evidence that a particular source converts better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classify at the browser boundary
&lt;/h2&gt;

&lt;p&gt;Start with a short list of sources you actually use. For example, recognize an exact search-engine hostname and a few publishing platforms; put other external hosts in an &lt;code&gt;other_referral&lt;/code&gt; bucket. A missing referrer belongs in &lt;code&gt;direct_unknown&lt;/code&gt;, because an app or privacy setting may have removed it.&lt;/p&gt;

&lt;p&gt;Avoid substring matching. A hostname containing a familiar brand is not necessarily that service. Parse the URL, compare the hostname with an explicit list, and send only the resulting label. Keep raw referrer URLs and arbitrary UTM strings out of the event payload and browser storage.&lt;/p&gt;

&lt;p&gt;Known UTM source tags can help when a publishing platform removes referrer information. They are still user-controlled inputs. Allowlist the tags and any campaign names separately, and validate them again on the server. Attribution labels are useful bookkeeping, not proof of authenticity or causation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the label across the journey
&lt;/h2&gt;

&lt;p&gt;Classifying every page independently turns an external visitor into an internal referral as soon as they follow a link. Instead, attach the entry label to a random, short-lived tab-session identifier and reuse it for later actions in that tab.&lt;/p&gt;

&lt;p&gt;In this implementation, the tab session expires after 30 minutes of inactivity. This does not measure a unique person or long-term retention. Two tabs can produce separate sessions; a copied tab can also behave differently from a fresh navigation. Document those limits before comparing the number with another analytics product's Visits metric.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the new schema a boundary
&lt;/h2&gt;

&lt;p&gt;When attribution is added to an existing event table, old events have unknown historical sources. Assign them a legacy schema version instead of inventing a source. Start a fresh measured session when the browser upgrades, and keep old cached clients compatible with the receiving endpoint.&lt;/p&gt;

&lt;p&gt;The source-to-action report can then filter for the new schema and exclude QA sessions. Count distinct sessions per action. Copying a prompt and generating an image are parallel tasks, so do not force both into one mandatory sequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the misleading cases
&lt;/h2&gt;

&lt;p&gt;Test an external landing followed by internal navigation; an unknown campaign string; a hostname that merely resembles an allowed one; an expired tab session; and an opt-out followed by re-enabling measurement. Also test that your own QA traffic stays excluded after navigation.&lt;/p&gt;

&lt;p&gt;The useful outcome is a report with understandable uncertainty. Missing attribution should remain visible, and failed telemetry must not stop someone from using the tool.&lt;/p&gt;

&lt;p&gt;The associated project is &lt;a href="https://retroprompt.site/80s-portrait-generator/?utm_source=devto&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;RetroPrompt's portrait workflow&lt;/a&gt;. I maintain the linked site; it currently creates still portraits, not video templates.&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>Measure completed actions, not just clicks, in a small AI tool</title>
      <dc:creator>yulingg zhang</dc:creator>
      <pubDate>Wed, 23 Sep 2026 07:48:56 +0000</pubDate>
      <link>https://dev.to/yulingg_zhang_dfbce7a345a/measure-completed-actions-not-just-clicks-in-a-small-ai-tool-18ag</link>
      <guid>https://dev.to/yulingg_zhang_dfbce7a345a/measure-completed-actions-not-just-clicks-in-a-small-ai-tool-18ag</guid>
      <description>&lt;p&gt;A new AI tool can attract visitors without telling its owner whether anyone finished a useful task. Page views alone cannot distinguish someone who copied a prompt from someone who selected a photo, submitted a generation request, or downloaded a result. Putting all these actions into one usage total makes the next product decision harder.&lt;/p&gt;

&lt;p&gt;I maintain RetroPrompt, a small portrait and prompt project. The lessons below come from its current event implementation, rather than a claimed conversion experiment. This article was prepared with AI assistance and does not present invented traffic or conversion numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with event definitions
&lt;/h2&gt;

&lt;p&gt;The first job is to write down what each event actually proves. In this implementation, prompt_copy is emitted after the browser's clipboard write resolves. photo_selected means the file picker supplied a file. generate_submit marks the start of a generation request. generate_success means the response passed basic checks and the result was assigned to the image area. generate_error records a controlled error category. download_click records a click on the download link.&lt;/p&gt;

&lt;p&gt;These distinctions matter. Selecting a photo does not prove that its upload completed. Clicking Download does not prove the file reached the visitor's device. Even a generation-success event needs a precise definition: the current frontend checks the response and its image-data prefix, then sets the image source. It does not wait for the image to finish decoding. If broken rendering becomes a problem, a separate decode check would provide stronger evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recognize more than one successful journey
&lt;/h2&gt;

&lt;p&gt;A visitor who copies a prompt and leaves may have completed their entire task. It would be misleading to count every such departure as a failed image-generation session.&lt;/p&gt;

&lt;p&gt;Treat the prompt-copy journey and the image-generation journey separately. For the second journey, look at photo selection, request submission, result delivery, and download clicks. For the first, check whether the visitor could find and copy a useful prompt. Keep the event meanings stable when comparing versions of a page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep your own tests visible in the data
&lt;/h2&gt;

&lt;p&gt;When a site is new, the maintainer's repeated testing can overwhelm the small sample of real activity. RetroPrompt uses an explicit QA switch, enabled with the qa=1 query parameter and remembered in browser storage and a cookie. Events carry a QA flag, and the page shows a test-mode indicator. Switching modes resets the current session identifier to reduce mixed test and non-test sessions.&lt;/p&gt;

&lt;p&gt;The reporting query still has to exclude flagged events. A frontend flag by itself does not clean a dashboard. This mechanism also operates per browser: a new device or cleared browser storage requires setting it again. It cannot establish that every visit by the maintainer has been excluded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the tool work when analytics fails
&lt;/h2&gt;

&lt;p&gt;Analytics requests should not block the action being measured. Here, event delivery uses a non-blocking fetch with keepalive and catches delivery failures. The visitor should still be able to copy a prompt or request an image when the analytics endpoint is unavailable.&lt;/p&gt;

&lt;p&gt;The trade-off is missing events. Privacy settings, blockers, interrupted connections, and closing the page can prevent delivery. Keepalive is not a guarantee. Compare browser events with separately defined server-side job records when investigating a mismatch; do not assume the two totals must agree exactly.&lt;/p&gt;

&lt;p&gt;The event payload is deliberately narrow: event name, page path, style identifier, controlled error category, random event and session identifiers, and the QA flag. It does not include the photo, full prompt, or raw exception stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn observations into questions
&lt;/h2&gt;

&lt;p&gt;Many copies but few generation requests could mean visitors prefer the prompt library. It could also mean the mobile generator entry is difficult to find. Photo selections without submissions suggest checking the button state, instructions, or quota display. Delivered results with few download clicks suggest checking the result presentation and download control.&lt;/p&gt;

&lt;p&gt;These are investigation paths, not causal conclusions. Verify the event definitions and delivery quality, inspect the relevant interaction, and then compare a targeted page change. A small, interpretable funnel is more useful than a large dashboard whose event names overstate what happened.&lt;/p&gt;

&lt;p&gt;For context, the workflow discussed here is on &lt;a href="https://retroprompt.site/80s-portrait-generator/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_extra_20260923" rel="noopener noreferrer"&gt;RetroPrompt's portrait generator&lt;/a&gt;. I maintain the linked project.&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>A timeout is not a failed job: handling daily quotas for an AI image generator</title>
      <dc:creator>yulingg zhang</dc:creator>
      <pubDate>Wed, 23 Sep 2026 06:05:30 +0000</pubDate>
      <link>https://dev.to/yulingg_zhang_dfbce7a345a/a-timeout-is-not-a-failed-job-handling-daily-quotas-for-an-ai-image-generator-213c</link>
      <guid>https://dev.to/yulingg_zhang_dfbce7a345a/a-timeout-is-not-a-failed-job-handling-daily-quotas-for-an-ai-image-generator-213c</guid>
      <description>&lt;p&gt;An image-generation request can disappear from the browser while the provider is still working. That makes a daily allowance a small accounting problem, not just a counter on a button.&lt;/p&gt;

&lt;p&gt;This note comes from the quota implementation in RetroPrompt, a portrait project I maintain. The implementation uses a Cloudflare Worker and D1. The important distinction is between a confirmed failure and an outcome we cannot yet confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three states, one invariant
&lt;/h2&gt;

&lt;p&gt;Each attempt has an ID, an account or anonymous-browser identifier, a UTC day, and a status: pending, success, or failed. The remaining allowance is:&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;remaining&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daily_limit&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;successful_attempts&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pending_attempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pending request reserves capacity before the provider call starts. A confirmed successful result consumes it. A confirmed failure stops counting against it.&lt;/p&gt;

&lt;p&gt;Do not implement reservation as a separate SELECT followed by an unconditional INSERT. Two concurrent requests could both see the same remaining slot. In this project's SQLite-backed implementation, the count check and conditional insert are one statement. A simplified version is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;utc_day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&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="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&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;FROM&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;utc_day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'success'&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;lt;&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check whether the insert actually wrote a row before starting expensive work. This is an example for a single SQLite database, not a claim that an arbitrary distributed database offers the same concurrency guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug was in the failure branch
&lt;/h2&gt;

&lt;p&gt;The provider returns an asynchronous task ID. The Worker polls that task until it completes, fails, or stops being observable within the request window.&lt;/p&gt;

&lt;p&gt;An earlier version threw an exception when polling returned an explicit failed status. The general exception handler then treated it like a timeout and left the attempt pending. The user lost access to a slot even though the provider had already confirmed failure.&lt;/p&gt;

&lt;p&gt;The fix is to handle that terminal status explicitly: update the attempt from pending to failed, return a clear rejection message, and recompute the allowance. Keep the update conditional on the previous status so an already finalized attempt is not accidentally rewritten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unknown is a different outcome
&lt;/h2&gt;

&lt;p&gt;A network timeout after submitting the task does not prove that generation failed. Neither does losing the response while retrieving the completed image. Automatically releasing a slot in these cases can let retries start additional paid jobs.&lt;/p&gt;

&lt;p&gt;The current conservative behavior keeps uncertain attempts pending until the next UTC allowance window. That protects the small daily budget, but it is a real user-experience limitation: someone may have to wait even when no image reached their browser. It is not a refund system or an exactly-once guarantee.&lt;/p&gt;

&lt;p&gt;A stronger future design would persist provider task IDs and reconcile pending attempts asynchronously. Provider-supported idempotency keys could also help, if their documented behavior matches the retry strategy. Those are improvements to investigate, not features this implementation already guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests that distinguish these cases
&lt;/h2&gt;

&lt;p&gt;A useful regression suite should exercise behavior rather than just check the displayed counter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A provider task explicitly fails: the reserved slot becomes available again.&lt;/li&gt;
&lt;li&gt;The submit or polling request times out: the reservation remains.&lt;/li&gt;
&lt;li&gt;Two requests compete for the last slot: only one reservation succeeds.&lt;/li&gt;
&lt;li&gt;A result completes: the attempt moves to success, with no double counting.&lt;/li&gt;
&lt;li&gt;The UTC day changes: the new allowance uses the new day bucket.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fixture-based provider tests can verify these transitions without spending image credits. They do not establish current provider uptime or output quality. Our recent regression work used that separation.&lt;/p&gt;

&lt;p&gt;There is another boundary: an anonymous browser cookie is not a verified person. Clearing it or switching browsers can create a new allowance identity. Strong abuse prevention requires a separate design; the daily counter alone does not provide it.&lt;/p&gt;

&lt;p&gt;Project context: &lt;a href="https://retroprompt.site/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=blog_20260923" rel="noopener noreferrer"&gt;RetroPrompt&lt;/a&gt;. I maintain the linked project. This article was prepared with AI assistance and checked against its implementation; it reports no traffic or performance benchmark.&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>Testing a deterministic browser game: seeds, replay and invalid state</title>
      <dc:creator>yulingg zhang</dc:creator>
      <pubDate>Sun, 06 Sep 2026 15:38:25 +0000</pubDate>
      <link>https://dev.to/yulingg_zhang_dfbce7a345a/testing-a-deterministic-browser-game-seeds-replay-and-invalid-state-12ob</link>
      <guid>https://dev.to/yulingg_zhang_dfbce7a345a/testing-a-deterministic-browser-game-seeds-replay-and-invalid-state-12ob</guid>
      <description>&lt;p&gt;A random game is easier to debug when the same inputs produce the same result. In HoopTrait, a browser basketball project, the Lab mode combines eight selected traits and generates a fictional career. The interesting engineering problem is keeping replay, sharing and validation consistent.&lt;/p&gt;

&lt;p&gt;This is a technical development note, not a claim that a game score predicts an athlete's real performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store the decisions, not just the result
&lt;/h2&gt;

&lt;p&gt;The Lab state records a seed, a dataset version and an ordered list of actions. An action is a pick or a reroll. Replaying those actions reconstructs the build.&lt;/p&gt;

&lt;p&gt;A seed alone is not a complete replay contract: changing the player pool or its order can change a seeded draw. A dataset version therefore matters alongside the random seed. For a future release, the same principle should apply to changes in the rules themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test invariants across many runs
&lt;/h2&gt;

&lt;p&gt;The Lab test suite iterates through 1,000 seeds. For each seed it shuffles the order of the eight skills, uses the two allowed rerolls, and completes a build. It checks that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Eight distinct players were selected.&lt;/li&gt;
&lt;li&gt;All eight traits are present, and no player remains to be drawn after completion.&lt;/li&gt;
&lt;li&gt;The overall game score stays between 0 and 99 and matches the shared rating function.&lt;/li&gt;
&lt;li&gt;Packing and unpacking the share state returns the original state.&lt;/li&gt;
&lt;li&gt;Recomputing the fictional career returns the same output.&lt;/li&gt;
&lt;li&gt;The ten simulated seasons sum to the displayed career earnings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those assertions catch different problems. A stable score does not prove that a shared link reproduces the same selections. A complete build does not prove that its season totals add up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reject impossible histories
&lt;/h2&gt;

&lt;p&gt;A share payload is untrusted input, even in a client-side game. Negative or fractional seeds, duplicate skill picks, a third reroll, unknown action types, a mismatched dataset version and actions after completion are rejected.&lt;/p&gt;

&lt;p&gt;The tests also cover malformed encoded payloads and unexpected fields. Local state is useful for a casual game, but it is not an anti-cheat system. A competitive leaderboard would need a separate trust model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make daily challenges explicit
&lt;/h2&gt;

&lt;p&gt;Daily challenges use a validated UTC date to derive the seed. The tests reject impossible dates and check that changing the date without the matching seed invalidates the state. This avoids treating a player's local midnight as a universal boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these tests do not establish
&lt;/h2&gt;

&lt;p&gt;Determinism proves repeatability, not realism. The career is an entertainment simulation, not a forecast. Unit tests also do not replace mobile interaction testing, data-quality review or permission checks for third-party assets.&lt;/p&gt;

&lt;p&gt;Project reference: &lt;a href="https://hooptrait.app/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=lab_testing_note" rel="noopener noreferrer"&gt;HoopTrait&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For developers working on small games: what would you version first when introducing a new scoring model—the replay format, the dataset, or both?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI assistance and checked against the project's Lab test source.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>gamedev</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
