<?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: Bharath Kumar</title>
    <description>The latest articles on DEV Community by Bharath Kumar (@bharath_kumar_39293).</description>
    <link>https://dev.to/bharath_kumar_39293</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3749483%2F3adde8be-ba7f-4e8d-8426-9d6db554d20a.png</url>
      <title>DEV Community: Bharath Kumar</title>
      <link>https://dev.to/bharath_kumar_39293</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bharath_kumar_39293"/>
    <language>en</language>
    <item>
      <title>How I Replaced react-calendar with a Timezone-Safe UnifiedDatePicker in a Production OSS Codebase</title>
      <dc:creator>Bharath Kumar</dc:creator>
      <pubDate>Sat, 23 May 2026 05:25:45 +0000</pubDate>
      <link>https://dev.to/bharath_kumar_39293/how-i-replaced-react-calendar-with-a-timezone-safe-unifieddatepicker-in-a-production-oss-codebase-4n5e</link>
      <guid>https://dev.to/bharath_kumar_39293/how-i-replaced-react-calendar-with-a-timezone-safe-unifieddatepicker-in-a-production-oss-codebase-4n5e</guid>
      <description>&lt;h1&gt;
  
  
  How I Replaced react-calendar with a Timezone-Safe UnifiedDatePicker in a Production OSS Codebase
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;A deep dive into adapter patterns, UTC boundary semantics, and the surprisingly subtle bugs that live inside date pickers.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Formbricks is an open-source survey and experience management platform built on Next.js. The codebase had accumulated multiple date picker implementations over time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A legacy &lt;code&gt;react-calendar&lt;/code&gt; component wrapped in a custom &lt;code&gt;DatePicker&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Two separate &lt;code&gt;react-day-picker&lt;/code&gt; v9 implementations in different parts of the app&lt;/li&gt;
&lt;li&gt;Native &lt;code&gt;&amp;lt;input type="date"&amp;gt;&lt;/code&gt; elements scattered across the contacts and segments UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one handled dates differently. None of them handled timezones correctly.&lt;/p&gt;

&lt;p&gt;Issue &lt;a href="https://github.com/formbricks/formbricks/issues/7774" rel="noopener noreferrer"&gt;#7774&lt;/a&gt; asked for a single unified component. This is the story of building it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Two Separate Bugs, One Root Cause
&lt;/h2&gt;

&lt;p&gt;Before writing any code, I audited every existing date picker call site. Two patterns kept appearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 1: &lt;code&gt;new Date(string)&lt;/code&gt; in onChange handlers&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In attribute-field-row.tsx (before)&lt;/span&gt;
&lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="o"&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;dateValue&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;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&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="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;valueField&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateValue&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;When a user in IST (UTC+5:30) types &lt;code&gt;2024-05-20&lt;/code&gt; into a native date input, the browser parses it as &lt;code&gt;2024-05-20T00:00:00+05:30&lt;/code&gt;. Calling &lt;code&gt;.toISOString()&lt;/code&gt; converts that to &lt;code&gt;2024-05-19T18:30:00.000Z&lt;/code&gt;. The stored value is May 19th. The user selected May 20th.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2: The custom range picker used &lt;code&gt;setHours&lt;/code&gt; instead of UTC&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In CustomFilter.tsx (before)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startOfRange&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;startOfRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHours&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="mi"&gt;0&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// local midnight, not UTC midnight&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setHours&lt;/code&gt; operates in local time. For a user in IST, "midnight" is &lt;code&gt;UTC-5:30&lt;/code&gt;, meaning the stored start-of-day is &lt;code&gt;18:30:00Z&lt;/code&gt; the previous day. Every range query was silently off.&lt;/p&gt;

&lt;p&gt;Both bugs share the same root cause: &lt;strong&gt;implicit reliance on local timezone behavior&lt;/strong&gt; in a system where dates are stored and queried in UTC.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture Decision: An Adapter Layer
&lt;/h2&gt;

&lt;p&gt;The fix isn't just replacing the components. It's establishing a &lt;strong&gt;single boundary&lt;/strong&gt; where date values enter and leave the system, and making timezone handling explicit at that boundary.&lt;/p&gt;

&lt;p&gt;I introduced &lt;code&gt;apps/web/lib/date-picker-adapter.ts&lt;/code&gt; with one core abstraction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Explicit calendar components — the safe intermediate representation.
 * All parsing produces a CalendarDate; all serialization consumes one.
 * month is 1-indexed (1 = January, 12 = December).
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1–12&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CalendarDate&lt;/code&gt; is a plain integer triple. No timezone, no implicit behavior. It's what a user sees on their screen when they click a day — just year, month, day. Every parsing path produces one. Every serialization path consumes one.&lt;/p&gt;

&lt;p&gt;The adapter supports three serialization modes, each covering a different use case in the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AdapterMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contact-iso&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;segment-range&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;analysis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ModeValue&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;M&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;AdapterMode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;M&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contact-iso&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;   &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="c1"&gt;// "2024-03-15T00:00:00.000Z"&lt;/span&gt;
  &lt;span class="nx"&gt;M&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;segment-range&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// ["2024-03-01T...", "2024-03-31T..."]&lt;/span&gt;
  &lt;span class="nx"&gt;M&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;analysis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;DateRange&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;// { from: Date, to: Date }&lt;/span&gt;
  &lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mapped type gives consuming components full type inference — &lt;code&gt;serializeToMode("contact-iso", cd)&lt;/code&gt; returns &lt;code&gt;string&lt;/code&gt;, &lt;code&gt;serializeToMode("segment-range", cd1, cd2)&lt;/code&gt; returns &lt;code&gt;[string, string]&lt;/code&gt;. No casting needed at call sites.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Key Insight: Two Different Date Sources
&lt;/h2&gt;

&lt;p&gt;The most important design decision in the adapter is having &lt;strong&gt;two separate functions&lt;/strong&gt; for extracting &lt;code&gt;CalendarDate&lt;/code&gt; from a &lt;code&gt;Date&lt;/code&gt; object.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * For stored/persisted dates — uses UTC getters.
 * Safe on any Date constructed via Date.UTC().
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fromUTCDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&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;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCFullYear&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCMonth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCDate&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="cm"&gt;/**
 * For browser UI interactions — uses LOCAL getters.
 * react-day-picker constructs onSelect dates at local midnight.
 * UTC getters would shift the day for IST/JST users.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toCalendarDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&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;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFullYear&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMonth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&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;Why two functions? Because &lt;code&gt;Date&lt;/code&gt; objects from two different sources behave differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stored values&lt;/strong&gt; come back from the database as ISO strings like &lt;code&gt;"2024-03-15T00:00:00.000Z"&lt;/code&gt;. When parsed, the UTC getters give the correct calendar date regardless of local timezone.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;UI click events&lt;/strong&gt; from react-day-picker give you a &lt;code&gt;Date&lt;/code&gt; constructed at &lt;strong&gt;local midnight&lt;/strong&gt;. An IST user clicking May 20th gets &lt;code&gt;Date("2024-05-20T00:00:00+05:30")&lt;/code&gt;. The UTC date on that object is May 19th. Using &lt;code&gt;getUTCDate()&lt;/code&gt; here would give you the wrong day.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mixing these two functions is the bug. Using them correctly is the fix.&lt;/p&gt;

&lt;p&gt;In the component, every &lt;code&gt;onSelect&lt;/code&gt; callback uses &lt;code&gt;toCalendarDate&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleSingleSelect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selectedDay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;selectedDay&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toCalendarDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selectedDay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// local getters — UI event&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iso&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;serializeToMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contact-iso&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;onContactIsoChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iso&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;onContactIsoChange&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;Every parsing path for stored values uses &lt;code&gt;fromUTCDate&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseAnalysis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DateRange&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&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="nl"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;fromUTCDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;// UTC getters — stored value&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;   &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;fromUTCDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="p"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Strict Parsing: Rejecting What &lt;code&gt;new Date()&lt;/code&gt; Silently Accepts
&lt;/h2&gt;

&lt;p&gt;The adapter's &lt;code&gt;parseYMDString&lt;/code&gt; function never calls &lt;code&gt;new Date(string)&lt;/code&gt;. Instead it uses a regex to extract integer components and validates them explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseYMDString&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d{4})&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;(\d{2})&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;(\d{2})&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&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="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;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;year&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="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;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="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;day&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="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="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isValidCalendarDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;day&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;code&gt;isValidCalendarDate&lt;/code&gt; checks for logical overflows — Feb 31, Apr 31, Feb 29 on non-leap years:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isValidCalendarDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;month&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;month&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;day&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;day&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;daysInMonth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why does this matter? &lt;code&gt;new Date("2024-02-31")&lt;/code&gt; in JavaScript doesn't throw — it silently normalizes to March 2nd. If corrupted data enters the database as &lt;code&gt;"2024-02-31"&lt;/code&gt;, a system using &lt;code&gt;new Date()&lt;/code&gt; would silently accept it and display a wrong date. The strict parser returns &lt;code&gt;null&lt;/code&gt; instead, surfacing the corruption.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bug We Caught in Code Review
&lt;/h2&gt;

&lt;p&gt;The first implementation of &lt;code&gt;serializeSegmentRange&lt;/code&gt; looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BUGGY: applies UTC boundaries before ordering&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;serializeSegmentRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orderedFrom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orderedTo&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;enforceRangeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;toUTCStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// from → 00:00:00.000Z&lt;/span&gt;
    &lt;span class="nf"&gt;toUTCEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// to   → 23:59:59.999Z&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orderedFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;orderedTo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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 test &lt;code&gt;auto-swaps a reversed range&lt;/code&gt; failed:&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="err"&gt;Expected:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-03-01T00:00:00.000Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Received:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-03-01T23:59:59.999Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;from&lt;/code&gt; is March 31 and &lt;code&gt;to&lt;/code&gt; is March 1 (reversed), the code first applies UTC boundaries: March 31 gets &lt;code&gt;00:00:00&lt;/code&gt; and March 1 gets &lt;code&gt;23:59:59&lt;/code&gt;. Then &lt;code&gt;enforceRangeOrder&lt;/code&gt; swaps the &lt;code&gt;Date&lt;/code&gt; objects chronologically. Now March 1 has the &lt;code&gt;23:59:59&lt;/code&gt; boundary and March 31 has &lt;code&gt;00:00:00&lt;/code&gt; — completely wrong.&lt;/p&gt;

&lt;p&gt;The fix is to swap the &lt;code&gt;CalendarDate&lt;/code&gt; integers &lt;em&gt;before&lt;/em&gt; applying UTC boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CORRECT: swap CalendarDates first, then apply boundaries&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;serializeSegmentRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;orderedFrom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orderedTo&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isCalendarDateAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&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;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&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="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&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="nf"&gt;toUTCStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderedFrom&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;toUTCEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderedTo&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a subtle but important distinction: &lt;strong&gt;range ordering must happen at the calendar layer, not the Date layer&lt;/strong&gt;, because the UTC boundaries are directional — they belong to specific ends of the range, not to specific dates.&lt;/p&gt;




&lt;h2&gt;
  
  
  Migrating CustomFilter: Removing Four States and Three Handlers
&lt;/h2&gt;

&lt;p&gt;The most complex migration was &lt;code&gt;CustomFilter.tsx&lt;/code&gt;, which handled the survey analysis date filter. The original had a hand-rolled range picker built directly in the component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: 4 states driving a manual state machine&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;filterRange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setFilterRange&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;selectingDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSelectingDate&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DateSelected&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;DateSelected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FROM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isDatePickerOpen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsDatePickerOpen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hoveredRange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setHoveredRange&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DateRange&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These four states powered a &lt;code&gt;handleDateChange&lt;/code&gt; function that tracked whether the user was picking the "from" or "to" date, managed hover highlighting, and handled edge cases like clicking a start date after the current end date. 80 lines of logic.&lt;/p&gt;

&lt;p&gt;After the migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: one state, one memo&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;showCustomPicker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setShowCustomPicker&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&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;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getDateRangeLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&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;label&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;getFilterDropDownLabels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;CUSTOM_RANGE&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="kc"&gt;false&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;filterRangeLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&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="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;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getFilterDropDownLabels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;ALL_TIME&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getDateRangeLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&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;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;filterRange&lt;/code&gt; state was replaced by a derived memo — it's now impossible for the displayed label to diverge from the actual &lt;code&gt;dateRange&lt;/code&gt; context value. The &lt;code&gt;showCustomPicker&lt;/code&gt; initializer uses a lazy function to correctly restore the custom picker state on page load if the user had previously set a custom range.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;DatePicker&lt;/code&gt; component handles all internal state for range selection, hover highlighting, and popover management. The parent component just passes a &lt;code&gt;value&lt;/code&gt; and &lt;code&gt;onChange&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;showCustomPicker&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DatePicker&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"analysis"&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dateRange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;range&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setDateRange&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&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="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;onClearDate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setShowCustomPicker&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;setDateRange&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getTodayDate&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="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;/&amp;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;Net result: -80 lines of stateful logic, +15 lines of declarative props.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  UTC Boundary Semantics
&lt;/h2&gt;

&lt;p&gt;One decision worth explaining explicitly: why &lt;code&gt;23:59:59.999Z&lt;/code&gt; for end-of-day instead of &lt;code&gt;00:00:00.000Z&lt;/code&gt; of the next day?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toUTCEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CalendarDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;month&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;cd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;59&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;999&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 Formbricks backend uses inclusive range queries: &lt;code&gt;WHERE timestamp &amp;gt;= from AND timestamp &amp;lt;= to&lt;/code&gt;. An event timestamped at &lt;code&gt;2024-03-31T23:59:45.000Z&lt;/code&gt; falls inside a range that ends at &lt;code&gt;2024-03-31T23:59:59.999Z&lt;/code&gt;. It would fall outside a range that ends at &lt;code&gt;2024-04-01T00:00:00.000Z&lt;/code&gt; only if the query uses &lt;code&gt;&amp;lt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;=&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Using &lt;code&gt;23:59:59.999Z&lt;/code&gt; makes the boundary semantics explicit and inclusive by default. If you know your backend uses exclusive upper bounds, adjust accordingly — that's a one-line change in &lt;code&gt;toUTCEnd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When time overrides are provided for analysis mode, the seconds/ms stay at their maximum values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// User sets end time to 17:30 → stored as 17:30:59.999Z, not 17:30:00.000Z&lt;/span&gt;
&lt;span class="c1"&gt;// This ensures events at exactly 17:30:XX are captured&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What We Left Out and Why
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Survey editor date inputs (&lt;code&gt;validation-rule-value-input.tsx&lt;/code&gt;):&lt;/strong&gt; The maintainer explicitly said not to touch survey UI in this PR — it would slow down the merge. This is the right call. Scope discipline matters more than completeness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storybook story:&lt;/strong&gt; The issue asked for one. We didn't ship it. Why? The &lt;code&gt;UnifiedDatePicker&lt;/code&gt; lives in &lt;code&gt;apps/web/modules/ui/components/&lt;/code&gt; but Formbricks' Storybook is configured to include only &lt;code&gt;packages/survey-ui/src/&lt;/code&gt;. Adding a story requires extending the Storybook config with &lt;code&gt;apps/web&lt;/code&gt; path aliases and mocking &lt;code&gt;useTranslation&lt;/code&gt; and Next.js imports. That's a separate PR.&lt;/p&gt;

&lt;p&gt;Calling this out honestly in the PR description is better than shipping a half-baked story or pretending it doesn't exist.&lt;/p&gt;




&lt;h2&gt;
  
  
  Process Lessons
&lt;/h2&gt;

&lt;p&gt;A few things from this experience that apply beyond this specific PR:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the actual file before writing the replacement.&lt;/strong&gt; Every migration started with &lt;code&gt;cat&lt;/code&gt;-ing the current implementation. The existing &lt;code&gt;DatePickerProps&lt;/code&gt; had three new props added since the previous PR attempt (&lt;code&gt;clearButtonId&lt;/code&gt;, &lt;code&gt;clearButtonLabel&lt;/code&gt;, &lt;code&gt;locale&lt;/code&gt;). Missing one would have broken the build silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify against the real codebase, not your mental model.&lt;/strong&gt; I assumed &lt;code&gt;DateRange&lt;/code&gt; from react-day-picker was &lt;code&gt;{ from: Date; to: Date }&lt;/code&gt;. It's actually &lt;code&gt;{ from: Date | undefined; to?: Date | undefined }&lt;/code&gt; — &lt;code&gt;to&lt;/code&gt; is optional, not required. That changes how you handle the analysis mode display text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual testing means actually clicking.&lt;/strong&gt; TypeScript and ESLint tell you the code compiles and follows style rules. They don't tell you if the popover opens in the wrong direction, if the calendar highlights the selected range correctly, or if the time inputs update the filter state in real time. Those require a browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One commit per logical change, verified at commit time.&lt;/strong&gt; The commit history is documentation. A reviewer reading &lt;code&gt;fix(contacts): migrate date-filter-value to UnifiedDatePicker (segment-range mode)&lt;/code&gt; knows exactly what changed and why. A commit called &lt;code&gt;fixes and stuff&lt;/code&gt; tells them nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Final Diff
&lt;/h2&gt;

&lt;p&gt;6 commits, 9 files, 1,011 additions, 366 deletions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;feat(date-picker): add datePickerAdapter with UTC normalization and 3 modes
feat(date-picker): implement UnifiedDatePicker component with react-day-picker v9
chore(date-picker): remove react-calendar dependency
fix(contacts): migrate date-filter-value to UnifiedDatePicker (segment-range mode)
fix(contacts): migrate attribute-field-row to UnifiedDatePicker (contact-iso mode)
fix(analysis): migrate CustomFilter to UnifiedDatePicker (analysis mode)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All CI checks passing. Waiting on maintainer review.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The PR is open at &lt;a href="https://github.com/formbricks/formbricks/pull/8103" rel="noopener noreferrer"&gt;formbricks/formbricks#8103&lt;/a&gt;. If you're working on something similar — replacing fragmented date handling in a Next.js app — the adapter pattern scales well. The key is establishing one boundary where timezone semantics are made explicit, and keeping everything else ignorant of timezones entirely.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>nextjs</category>
      <category>opensource</category>
      <category>react</category>
    </item>
    <item>
      <title>The Silent Bug: How a DOM Click Target Issue Was Breaking Formbricks Surveys</title>
      <dc:creator>Bharath Kumar</dc:creator>
      <pubDate>Sun, 19 Apr 2026 08:18:02 +0000</pubDate>
      <link>https://dev.to/bharath_kumar_39293/the-silent-bug-how-a-dom-click-target-issue-was-breaking-formbricks-surveys-1fch</link>
      <guid>https://dev.to/bharath_kumar_39293/the-silent-bug-how-a-dom-click-target-issue-was-breaking-formbricks-surveys-1fch</guid>
      <description>&lt;p&gt;Here's something that will frustrate you once you see it.&lt;br&gt;
You set up a Formbricks survey trigger. Configure it to fire when a user clicks .submit-btn. Deploy it. Test it yourself — works perfectly. Ship it.&lt;br&gt;
Then nothing happens. Zero surveys triggered. No errors. No warnings. Just silence.&lt;br&gt;
That's the bug I fixed in PR #7327. And the reason it's interesting isn't the fix itself — it's what it taught me about how SDKs fail in the real world.&lt;/p&gt;

&lt;p&gt;What Was Actually Breaking&lt;br&gt;
The Formbricks JS SDK lets you trigger surveys based on user actions — including CSS selector click actions. You tell it "when someone clicks .feedback-btn, show this survey."&lt;br&gt;
The SDK listened for click events and checked if the clicked element matched your selector:&lt;br&gt;
typescriptif (!targetElement.matches(".feedback-btn")) {&lt;br&gt;
  return false // action dropped, survey never shows&lt;br&gt;
}&lt;br&gt;
Looks fine. Works fine — until your button has any content inside it.&lt;br&gt;
html&lt;br&gt;
  ...&lt;br&gt;
  Give Feedback&lt;br&gt;
&lt;br&gt;
Now when a user clicks the SVG icon inside the button, event.target is the  — not the .feedback-btn. The .matches() check runs against the SVG. It returns false. The survey is dropped silently.&lt;br&gt;
The only way to trigger the survey was to click the exact 1-2px padding of the button where no child element exists. Which nobody does.&lt;/p&gt;

&lt;p&gt;Why Nobody Reported It Directly&lt;br&gt;
This is the part that stuck with me.&lt;br&gt;
The bug had almost certainly been there for a while. But nobody filed an issue saying "event.target doesn't match the selector for nested elements." They filed issues saying "the survey trigger doesn't work reliably" or "only fires sometimes." They assumed it was a configuration problem and gave up.&lt;br&gt;
The bug was invisible because it failed silently. No console error. No warning. Just... nothing.&lt;br&gt;
This is a classic SDK failure mode — the kind that's hard to debug because the feedback loop is broken. The user did everything right. The SDK said nothing. The survey never showed.&lt;/p&gt;

&lt;p&gt;How Common Was This Really?&lt;br&gt;
Extremely common. This affects virtually every real-world button.&lt;br&gt;
Modern design systems — shadcn/ui, Radix UI, MUI, Headless UI — almost always put content inside buttons. Icon buttons. Buttons with text wrappers. Buttons with badges. Every single one of these would silently fail with the old behavior.&lt;br&gt;
When I demonstrated the reproduction to Dhruwang:&lt;/p&gt;

&lt;p&gt;Click the SVG → Survey does not trigger ❌&lt;br&gt;
Click the text → Survey does not trigger ❌&lt;br&gt;
Click the 1-2px button edge → Survey triggers ✅&lt;/p&gt;

&lt;p&gt;His response: "Looks good 🚀" — merged.&lt;/p&gt;

&lt;p&gt;The Fix: .closest() as a Fallback&lt;br&gt;
The solution is a DOM method called .closest(). It walks up the DOM tree from the clicked element until it finds an ancestor that matches the selector.&lt;br&gt;
typescript// Before — only checks the exact clicked element&lt;br&gt;
if (!targetElement.matches(selector)) return false&lt;/p&gt;

&lt;p&gt;// After — falls back to checking ancestors&lt;br&gt;
const matchesDirectly = targetElement.matches(cssSelector)&lt;/p&gt;

&lt;p&gt;if (!matchesDirectly) {&lt;br&gt;
  const ancestor = targetElement.closest(cssSelector)&lt;br&gt;
  if (!ancestor) return false&lt;br&gt;
  matchedElement = ancestor // use the button, not the SVG&lt;br&gt;
}&lt;br&gt;
When the user clicks the SVG icon, .closest(".feedback-btn") walks up the DOM, finds the parent button, and returns it. The survey fires correctly.&lt;br&gt;
Performance note: .closest() is only called as a fallback. If the direct match succeeds — which it does for simple elements — the code takes the same fast path as before. No regression for the common case.&lt;/p&gt;

&lt;p&gt;What This Taught Me About SDK Design&lt;br&gt;
Three things that I keep coming back to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Silent failures are worse than loud failures.
An error in the console is annoying. A survey that silently never fires is a support ticket three weeks later when the customer asks why they have zero responses. SDKs that fail silently destroy trust slowly. If the fix fails for some reason, it should say so.&lt;/li&gt;
&lt;li&gt;The gap between "works in testing" and "works in production" is the DOM.
In testing you click the button. In production users click whatever their cursor lands on — which is almost always a child element. The SDK has to handle the messy reality of how people actually interact with interfaces, not the clean version you test with.&lt;/li&gt;
&lt;li&gt;Event delegation is harder than it looks.
event.target gives you the most specific element that was clicked. That's often not the element you care about. Any SDK that listens to click events and matches CSS selectors needs to account for this — otherwise it breaks on every button with an icon.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Regression Tests&lt;br&gt;
I added three tests that fail on the old code and pass on the new:&lt;br&gt;
✅ Clicking a child inside .my-btn → action fires correctly&lt;br&gt;
✅ Clicking an element with no matching ancestor → correctly returns false&lt;br&gt;&lt;br&gt;
✅ Clicking the target directly → .closest() is not called (fast path preserved)&lt;br&gt;
The third test matters. It confirms the fix doesn't slow down the common case. .closest() is only invoked when the direct match fails.&lt;br&gt;
232 tests. 19 files. All passing.&lt;/p&gt;

&lt;p&gt;Why I Picked This Up&lt;br&gt;
I was exploring the Formbricks codebase looking for reliability gaps — places where the SDK could fail silently without the developer knowing. This was one of the clearest examples I found.&lt;br&gt;
The issue (#7314) had been sitting open. The reproduction wasn't obvious unless you thought about how click events actually propagate through the DOM. Once I understood it, the fix was clear.&lt;br&gt;
That's usually how it goes with SDK bugs. Understanding the problem takes 90% of the time. Writing the fix takes 10%.&lt;/p&gt;

&lt;p&gt;Links&lt;/p&gt;

&lt;p&gt;PR #7327: &lt;a href="https://github.com/formbricks/formbricks/pull/7327" rel="noopener noreferrer"&gt;https://github.com/formbricks/formbricks/pull/7327&lt;/a&gt;&lt;br&gt;
My GitHub: &lt;a href="https://github.com/bharathkumar39293" rel="noopener noreferrer"&gt;https://github.com/bharathkumar39293&lt;/a&gt;&lt;br&gt;
WebhookDrop (another project in this space): &lt;a href="https://web-hook-drop-t4k6.vercel.app" rel="noopener noreferrer"&gt;https://web-hook-drop-t4k6.vercel.app&lt;/a&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I Fixed a DoS Vulnerability in Formbricks — and Added a Second Layer Nobody Asked For</title>
      <dc:creator>Bharath Kumar</dc:creator>
      <pubDate>Thu, 09 Apr 2026 11:10:45 +0000</pubDate>
      <link>https://dev.to/bharath_kumar_39293/i-fixed-a-dos-vulnerability-in-formbricks-and-added-a-second-layer-nobody-asked-for-28ab</link>
      <guid>https://dev.to/bharath_kumar_39293/i-fixed-a-dos-vulnerability-in-formbricks-and-added-a-second-layer-nobody-asked-for-28ab</guid>
      <description>&lt;p&gt;A story about picking up a security issue, going beyond the spec, and what defense-in-depth actually means in practice&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The issue&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Someone opened a GitHub issue on Formbricks pointing out that the &lt;code&gt;userId&lt;/code&gt; parameter in the SDK had no length validation. Next.js's 4MB default body limit was the only thing standing between a bad actor and the server.&lt;/p&gt;

&lt;p&gt;The fix suggested was straightforward: add &lt;code&gt;.max(255)&lt;/code&gt; to the Zod schema. That's it.&lt;/p&gt;

&lt;p&gt;I picked it up the same day. But as I dug in, I realized the schema fix alone wasn't enough.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Why 255?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before writing a single line, I thought about what userIds actually look like in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UUIDs: 36 characters&lt;/li&gt;
&lt;li&gt;Emails (RFC 5321 max): 254 characters
&lt;/li&gt;
&lt;li&gt;Custom IDs: typically tens to hundreds of characters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;255 covers everything real. It rejects everything abusive. The number isn't arbitrary — it's the smallest limit that breaks nothing legitimate.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The schema fix (Layer 1)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The issue pointed at one schema. I found four that needed fixing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/types/displays.ts&lt;/span&gt;
&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;User ID cannot exceed 255 characters&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// packages/types/js.ts&lt;/span&gt;
&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// ZJsUserIdentifyInput&lt;/span&gt;
&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// ZJsPersonSyncParams&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This validates at the API boundary — if an oversized &lt;code&gt;userId&lt;/code&gt; reaches the server, it gets rejected before touching the database.&lt;/p&gt;

&lt;p&gt;But here's what bothered me: &lt;em&gt;the payload still travels over the network first.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The SDK guard (Layer 2)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Formbricks JS SDK runs in the browser. &lt;code&gt;setUserId()&lt;/code&gt; is called client-side. If I only validate on the server, a 4MB string still gets serialized, sent over the network, and processed by Next.js before being rejected.&lt;/p&gt;

&lt;p&gt;That's wasteful at best. At scale with many concurrent requests, it's a real resource drain.&lt;/p&gt;

&lt;p&gt;So I added an early rejection guard directly in &lt;code&gt;user.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_USER_ID_LENGTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&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;userId&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;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_USER_ID_LENGTH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`UserId exceeds maximum length of &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MAX_USER_ID_LENGTH&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; characters`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;okVoid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs before &lt;code&gt;updateQueue.updateUserId()&lt;/code&gt; is ever called. The oversized string never leaves the browser. No network call. No server processing. No database touch.&lt;/p&gt;

&lt;p&gt;The issue didn't ask for this. But once I saw the attack surface clearly, the schema fix alone felt incomplete.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The test&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I added a unit test to lock in this behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;should reject userId longer than 255 characters and not send updates&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;longId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setUserId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;longId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mockLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalledWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UserId exceeds maximum length of 255 characters&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mockUpdateQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updateUserId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalled&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mockUpdateQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processUpdates&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalled&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 test verifies three things: the function returns cleanly, the error is logged, and the update queue is never triggered. Future refactors can't accidentally regress this silently.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What I learned&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The schema fix was the correct answer to the issue as written. The SDK guard was the correct answer to the actual problem.&lt;/p&gt;

&lt;p&gt;These are different things. Reading an issue description and reading the underlying risk are different skills. The description tells you what to change. The risk tells you &lt;em&gt;why&lt;/em&gt;, and once you understand why, you often see that the suggested change is necessary but not sufficient.&lt;/p&gt;

&lt;p&gt;Defense in depth isn't a fancy term. It just means: don't rely on a single check. If the client-side guard fails or gets bypassed somehow, the server-side schema catches it. If someone calls the API directly without the SDK, the schema catches it. Two independent layers, neither depending on the other.&lt;/p&gt;

&lt;p&gt;The PR got merged. Matti left a note: &lt;em&gt;"The additional validation makes sense."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's the whole story.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Links&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Merged PR: &lt;a href="https://github.com/formbricks/formbricks/pull/7378" rel="noopener noreferrer"&gt;#7378&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Original issue: &lt;a href="https://github.com/formbricks/formbricks/issues/7375" rel="noopener noreferrer"&gt;#7375&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;My GitHub: &lt;a href="https://github.com/bharathkumar39293" rel="noopener noreferrer"&gt;bharathkumar39293&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;I'm a final year CS student graduating in 2026, looking for backend/infra roles. If this kind of thinking interests your team, I'd love to connect.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>typescript</category>
      <category>opensource</category>
      <category>node</category>
    </item>
    <item>
      <title>I Built a Rate Limiter SDK from Scratch — Here's Every Decision I Made and Why</title>
      <dc:creator>Bharath Kumar</dc:creator>
      <pubDate>Sun, 05 Apr 2026 03:27:18 +0000</pubDate>
      <link>https://dev.to/bharath_kumar_39293/i-built-a-rate-limiter-sdk-from-scratch-heres-every-decision-i-made-and-why-54k4</link>
      <guid>https://dev.to/bharath_kumar_39293/i-built-a-rate-limiter-sdk-from-scratch-heres-every-decision-i-made-and-why-54k4</guid>
      <description>&lt;p&gt;I'm a final-year CS student who contributes to open source — Formbricks, Trigger.dev. While doing that I kept running into the same class of problems: rate limiting, retry logic, SDK reliability.&lt;br&gt;
So I built a rate limiter SDK from scratch. Not to follow a tutorial. To actually understand every decision.&lt;br&gt;
This post is about those decisions — why Redis over PostgreSQL, why sliding window over fixed window, why fail-open over fail-closed, and a few others. Each one taught me something that no tutorial ever explained.&lt;br&gt;
Live demo: &lt;a href="https://rate-limiter-sdk.vercel.app" rel="noopener noreferrer"&gt;https://rate-limiter-sdk.vercel.app&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/bharathkumar39293/Rate-Limiter-SDK" rel="noopener noreferrer"&gt;https://github.com/bharathkumar39293/Rate-Limiter-SDK&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What I built&lt;br&gt;
A rate limiter that any Node.js developer can drop into their app with one npm install:&lt;br&gt;
typescriptimport { RateLimiterClient } from 'rate-limiter-sdk'&lt;/p&gt;

&lt;p&gt;const limiter = new RateLimiterClient({&lt;br&gt;
  apiKey: 'your-api-key',&lt;br&gt;
  serverUrl: '&lt;a href="https://your-server.com" rel="noopener noreferrer"&gt;https://your-server.com&lt;/a&gt;'&lt;br&gt;
})&lt;/p&gt;

&lt;p&gt;const result = await limiter.check({ userId: 'user_123', limit: 100, window: 60 })&lt;/p&gt;

&lt;p&gt;if (!result.allowed) {&lt;br&gt;
  return res.status(429).json({ retryAfter: result.retryAfter })&lt;br&gt;
}&lt;br&gt;
One line. Everything handled. That's the goal of an SDK — hide the complexity so the developer never has to think about it.&lt;br&gt;
The stack: TypeScript, Node.js, Express, Redis, PostgreSQL, Docker. Let me walk through the decisions.&lt;/p&gt;

&lt;p&gt;Decision 1: Redis over PostgreSQL for the rate limiting logic&lt;br&gt;
This was the first question I had to answer. I already know PostgreSQL. Why bring in Redis at all?&lt;br&gt;
The answer is simple once you think about it.&lt;br&gt;
Rate limiting happens on every single request — before anything else runs. At scale that's thousands of times per second. PostgreSQL lives on disk. Every query is a disk read. That's fine for storing user data. It's not fine for something that needs to respond in under a millisecond.&lt;br&gt;
Redis lives in RAM. No disk. The difference is roughly 100 nanoseconds (Redis) vs 10 milliseconds (PostgreSQL). That's 100,000x faster.&lt;br&gt;
So the rule became clear: Redis for real-time decisions. PostgreSQL for permanent history. Different jobs, different tools.&lt;/p&gt;

&lt;p&gt;Decision 2: Sliding window over fixed window&lt;br&gt;
This is the one I get asked about most. Both algorithms count requests over a time window — but they behave very differently under pressure.&lt;br&gt;
Fixed window divides time into rigid buckets: 0-60s, 60-120s, and so on. Limit is 100 requests per bucket. Sounds fine.&lt;br&gt;
The problem: a user can send 100 requests at second 59 and another 100 at second 61. That's 200 requests in 2 seconds — double the limit — and both batches pass the check. The bucket boundary is a hole.&lt;br&gt;
Sliding window doesn't use buckets. The window always looks back exactly N seconds from right now. If you sent 100 requests in the last 60 seconds, you're blocked. Doesn't matter when the clock ticks over.&lt;br&gt;
The implementation uses a Redis sorted set. Each request is stored as an entry with its timestamp as the score. To check the limit:&lt;br&gt;
typescript// Remove entries older than the window&lt;br&gt;
await redis.zremrangebyscore(key, 0, now - windowMs)&lt;/p&gt;

&lt;p&gt;// Count what's left — these are all within the window&lt;br&gt;
const count = await redis.zcard(key)&lt;/p&gt;

&lt;p&gt;// Make the decision&lt;br&gt;
if (count &amp;gt;= limit) return { allowed: false, retryAfter: ... }&lt;/p&gt;

&lt;p&gt;// Allow — add this request&lt;br&gt;
await redis.zadd(key, now, requestId)&lt;br&gt;
Four lines of logic. The sliding window moves automatically because we always remove old entries before counting.&lt;br&gt;
Stripe uses sliding window. Cloudflare uses sliding window. There's a reason.&lt;/p&gt;

&lt;p&gt;Decision 3: Fail-open over fail-closed&lt;br&gt;
This was the most important design decision in the SDK client — and the one that took the longest to think through.&lt;br&gt;
When the rate limiter server is unreachable (network down, timeout, crash), the SDK has two options:&lt;/p&gt;

&lt;p&gt;Fail closed → block all requests. Safe, strict.&lt;br&gt;
Fail open → allow all requests. Risky, but resilient.&lt;/p&gt;

&lt;p&gt;I chose fail-open. Here's why.&lt;br&gt;
My rate limiter is a secondary service. It exists to protect the developer's app — not to be the app itself. If my server goes down and I fail closed, I just blocked every user of every app that's using my SDK. The developer's product is now broken because of my infrastructure problem.&lt;br&gt;
That's a worse outcome than allowing a few extra requests temporarily.&lt;br&gt;
typescript} catch (error: any) {&lt;br&gt;
  // Server unreachable — fail open&lt;br&gt;
  if (!error.response) {&lt;br&gt;
    console.warn('[RateLimiter] Server unreachable — failing open')&lt;br&gt;
    return { allowed: true, remaining: -1 }&lt;br&gt;
  }&lt;br&gt;
  return error.response.data&lt;br&gt;
}&lt;br&gt;
The remaining: -1 is a deliberate signal. Negative remaining means "we allowed this but couldn't actually check." Developers who want to monitor fail-open events can watch for it.&lt;br&gt;
The principle: never let your secondary service take down someone's primary app.&lt;/p&gt;

&lt;p&gt;Decision 4: Fire-and-forget for PostgreSQL logging&lt;br&gt;
Every request — allowed or rejected — gets logged to PostgreSQL for analytics. But I don't await the log call.&lt;br&gt;
typescriptconst result = await checkRateLimit(apiKey, userId, limit, window)&lt;/p&gt;

&lt;p&gt;// No await — fire and forget&lt;br&gt;
logRequest({ apiKey, userId, allowed: result.allowed, remaining: result.remaining })&lt;/p&gt;

&lt;p&gt;// Response goes out immediately&lt;br&gt;
return res.status(result.allowed ? 200 : 429).json(result)&lt;br&gt;
Why? Because the client doesn't care about logging. The decision is already made. If I await the PostgreSQL write, I'm adding ~5ms of latency to every single request — for something the client gets zero value from.&lt;br&gt;
Fire-and-forget: start the operation, send the response immediately, let the log finish in the background.&lt;br&gt;
The tradeoff: if the server crashes in that 5ms window, the log is lost. That's acceptable for analytics data.&lt;br&gt;
The rule: never make clients wait for things they don't care about.&lt;/p&gt;

&lt;p&gt;Decision 5: In-memory cache for API key validation&lt;br&gt;
Every request needs to validate the API key against PostgreSQL. But if I hit the database on every single request, I'm adding a DB round-trip to every rate limit check — defeating the purpose of using Redis for speed.&lt;br&gt;
The solution is an in-memory Set:&lt;br&gt;
typescriptconst validKeys = new Set()&lt;/p&gt;

&lt;p&gt;export async function authMiddleware(req, res, next) {&lt;br&gt;
  const apiKey = req.headers['x-api-key']&lt;/p&gt;

&lt;p&gt;// Fast path — already verified&lt;br&gt;
  if (validKeys.has(apiKey)) return next()&lt;/p&gt;

&lt;p&gt;// Slow path — first time seeing this key&lt;br&gt;
  const result = await db.query('SELECT id FROM api_keys WHERE key = $1', [apiKey])&lt;br&gt;
  if (result.rows.length === 0) return res.status(401).json({ error: 'Invalid API key' })&lt;/p&gt;

&lt;p&gt;// Cache it for next time&lt;br&gt;
  validKeys.add(apiKey)&lt;br&gt;
  next()&lt;br&gt;
}&lt;br&gt;
First request from a key: hits PostgreSQL (~5ms). Every subsequent request: hits the Set (~0.001ms). At scale that's thousands of database queries saved per second.&lt;br&gt;
The Set resets on server restart — which is fine. The DB is the source of truth. This is just a speed layer.&lt;/p&gt;

&lt;p&gt;Decision 6: Plain React over Next.js for the dashboard&lt;br&gt;
This one is simple but I get asked about it.&lt;br&gt;
The dashboard is an internal analytics tool. It shows request counts, blocked percentages, per-user breakdowns. Nobody is Googling for it. There are no public pages to index.&lt;br&gt;
Next.js is great for server-side rendering and SEO. Neither of those things matter for an internal dashboard that only authenticated users see.&lt;br&gt;
Adding Next.js for this use case is overengineering. Plain React, talking to the Express API, is exactly the right tool.&lt;br&gt;
The principle: use the simplest tool that solves the problem correctly.&lt;/p&gt;

&lt;p&gt;Decision 7: 2-second timeout on every SDK call&lt;br&gt;
The SDK calls my server on every limiter.check() call. If my server is slow — maybe it's under load, maybe it's in the middle of a deploy — the SDK should not hang the developer's app indefinitely.&lt;br&gt;
typescriptconst response = await axios.post(serverUrl, options, {&lt;br&gt;
  headers: { 'x-api-key': this.apiKey },&lt;br&gt;
  timeout: 2000  // give up after 2 seconds&lt;br&gt;
})&lt;br&gt;
Two seconds is the threshold. After that, the request times out, the catch block runs, and we fail-open. The developer's app never hangs waiting for my server.&lt;/p&gt;

&lt;p&gt;What I learned&lt;br&gt;
Building this taught me something I didn't expect: the interesting part of backend engineering is almost never the happy path.&lt;br&gt;
Anyone can write the code that works when everything is fine. The decisions that matter are:&lt;/p&gt;

&lt;p&gt;What happens when Redis goes down?&lt;br&gt;
What happens when the DB is slow?&lt;br&gt;
What happens when two requests arrive at the same millisecond?&lt;br&gt;
How do you make it fast without making it fragile?&lt;/p&gt;

&lt;p&gt;These are the questions that show up in production. Building this project — and contributing to Formbricks and Trigger.dev — forced me to think about all of them.&lt;br&gt;
That's why I built it. Not to add a line to a resume. To actually understand the problems.&lt;/p&gt;

&lt;p&gt;Links&lt;/p&gt;

&lt;p&gt;Live demo: &lt;a href="https://rate-limiter-sdk.vercel.app" rel="noopener noreferrer"&gt;https://rate-limiter-sdk.vercel.app&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/bharathkumar39293/Rate-Limiter-SDK" rel="noopener noreferrer"&gt;https://github.com/bharathkumar39293/Rate-Limiter-SDK&lt;/a&gt;&lt;br&gt;
My other project (webhook delivery engine): &lt;a href="https://web-hook-drop-t4k6.vercel.app" rel="noopener noreferrer"&gt;https://web-hook-drop-t4k6.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're building something similar or have questions about any of these decisions — drop a comment. Happy to dig into it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>redis</category>
      <category>typescript</category>
      <category>node</category>
    </item>
  </channel>
</rss>
