DEV Community

Bharath Kumar
Bharath Kumar

Posted on

How I Replaced react-calendar with a Timezone-Safe UnifiedDatePicker in a Production OSS Codebase

How I Replaced react-calendar with a Timezone-Safe UnifiedDatePicker in a Production OSS Codebase

A deep dive into adapter patterns, UTC boundary semantics, and the surprisingly subtle bugs that live inside date pickers.


The Setup

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

  • A legacy react-calendar component wrapped in a custom DatePicker
  • Two separate react-day-picker v9 implementations in different parts of the app
  • Native <input type="date"> elements scattered across the contacts and segments UI

Each one handled dates differently. None of them handled timezones correctly.

Issue #7774 asked for a single unified component. This is the story of building it.


The Problem: Two Separate Bugs, One Root Cause

Before writing any code, I audited every existing date picker call site. Two patterns kept appearing.

Bug 1: new Date(string) in onChange handlers

// In attribute-field-row.tsx (before)
onChange={(e) => {
  const dateValue = e.target.value ? new Date(e.target.value).toISOString() : "";
  valueField.onChange(dateValue);
}}
Enter fullscreen mode Exit fullscreen mode

When a user in IST (UTC+5:30) types 2024-05-20 into a native date input, the browser parses it as 2024-05-20T00:00:00+05:30. Calling .toISOString() converts that to 2024-05-19T18:30:00.000Z. The stored value is May 19th. The user selected May 20th.

Bug 2: The custom range picker used setHours instead of UTC

// In CustomFilter.tsx (before)
const startOfRange = new Date(date);
startOfRange.setHours(0, 0, 0, 0); // local midnight, not UTC midnight
Enter fullscreen mode Exit fullscreen mode

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

Both bugs share the same root cause: implicit reliance on local timezone behavior in a system where dates are stored and queried in UTC.


The Architecture Decision: An Adapter Layer

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

I introduced apps/web/lib/date-picker-adapter.ts with one core abstraction:

/**
 * Explicit calendar components — the safe intermediate representation.
 * All parsing produces a CalendarDate; all serialization consumes one.
 * month is 1-indexed (1 = January, 12 = December).
 */
export interface CalendarDate {
  readonly year: number;
  readonly month: number; // 1–12
  readonly day: number;
}
Enter fullscreen mode Exit fullscreen mode

CalendarDate 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.

The adapter supports three serialization modes, each covering a different use case in the app:

export type AdapterMode = "contact-iso" | "segment-range" | "analysis";

export type ModeValue<M extends AdapterMode> =
  M extends "contact-iso"   ? string :           // "2024-03-15T00:00:00.000Z"
  M extends "segment-range" ? [string, string] : // ["2024-03-01T...", "2024-03-31T..."]
  M extends "analysis"      ? DateRange :        // { from: Date, to: Date }
  never;
Enter fullscreen mode Exit fullscreen mode

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


The Key Insight: Two Different Date Sources

The most important design decision in the adapter is having two separate functions for extracting CalendarDate from a Date object.

/**
 * For stored/persisted dates — uses UTC getters.
 * Safe on any Date constructed via Date.UTC().
 */
export function fromUTCDate(date: Date): CalendarDate {
  return {
    year:  date.getUTCFullYear(),
    month: date.getUTCMonth() + 1,
    day:   date.getUTCDate(),
  };
}

/**
 * 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.
 */
export function toCalendarDate(date: Date): CalendarDate {
  return {
    year:  date.getFullYear(),
    month: date.getMonth() + 1,
    day:   date.getDate(),
  };
}
Enter fullscreen mode Exit fullscreen mode

Why two functions? Because Date objects from two different sources behave differently:

  • Stored values come back from the database as ISO strings like "2024-03-15T00:00:00.000Z". When parsed, the UTC getters give the correct calendar date regardless of local timezone.

  • UI click events from react-day-picker give you a Date constructed at local midnight. An IST user clicking May 20th gets Date("2024-05-20T00:00:00+05:30"). The UTC date on that object is May 19th. Using getUTCDate() here would give you the wrong day.

Mixing these two functions is the bug. Using them correctly is the fix.

In the component, every onSelect callback uses toCalendarDate:

const handleSingleSelect = useCallback(
  (selectedDay: Date | undefined) => {
    if (!selectedDay) return;
    const cd = toCalendarDate(selectedDay); // local getters — UI event
    const iso = serializeToMode("contact-iso", cd);
    onContactIsoChange(iso);
  },
  [onContactIsoChange]
);
Enter fullscreen mode Exit fullscreen mode

Every parsing path for stored values uses fromUTCDate:

function parseAnalysis(value: DateRange): { from: CalendarDate | null; to: CalendarDate | null } {
  return {
    from: value.from ? fromUTCDate(value.from) : null, // UTC getters — stored value
    to:   value.to   ? fromUTCDate(value.to)   : null,
  };
}
Enter fullscreen mode Exit fullscreen mode

Strict Parsing: Rejecting What new Date() Silently Accepts

The adapter's parseYMDString function never calls new Date(string). Instead it uses a regex to extract integer components and validates them explicitly:

export function parseYMDString(s: string): CalendarDate | null {
  const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
  if (!match) return null;

  const year  = parseInt(match[1], 10);
  const month = parseInt(match[2], 10);
  const day   = parseInt(match[3], 10);

  if (!isValidCalendarDate(year, month, day)) return null;
  return { year, month, day };
}
Enter fullscreen mode Exit fullscreen mode

isValidCalendarDate checks for logical overflows — Feb 31, Apr 31, Feb 29 on non-leap years:

export function isValidCalendarDate(year: number, month: number, day: number): boolean {
  if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return false;
  if (month < 1 || month > 12) return false;
  if (day < 1 || day > daysInMonth(year, month)) return false;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

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


The Bug We Caught in Code Review

The first implementation of serializeSegmentRange looked like this:

// BUGGY: applies UTC boundaries before ordering
function serializeSegmentRange(from: CalendarDate, to: CalendarDate): [string, string] {
  const [orderedFrom, orderedTo] = enforceRangeOrder(
    toUTCStart(from), // from → 00:00:00.000Z
    toUTCEnd(to)      // to   → 23:59:59.999Z
  );
  return [orderedFrom.toISOString(), orderedTo.toISOString()];
}
Enter fullscreen mode Exit fullscreen mode

The test auto-swaps a reversed range failed:

Expected: "2024-03-01T00:00:00.000Z"
Received: "2024-03-01T23:59:59.999Z"
Enter fullscreen mode Exit fullscreen mode

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

The fix is to swap the CalendarDate integers before applying UTC boundaries:

// CORRECT: swap CalendarDates first, then apply boundaries
function serializeSegmentRange(from: CalendarDate, to: CalendarDate): [string, string] {
  const [orderedFrom, orderedTo] = isCalendarDateAfter(from, to) ? [to, from] : [from, to];
  return [toUTCStart(orderedFrom).toISOString(), toUTCEnd(orderedTo).toISOString()];
}
Enter fullscreen mode Exit fullscreen mode

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


Migrating CustomFilter: Removing Four States and Three Handlers

The most complex migration was CustomFilter.tsx, which handled the survey analysis date filter. The original had a hand-rolled range picker built directly in the component:

// Before: 4 states driving a manual state machine
const [filterRange, setFilterRange] = useState(...)
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM)
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false)
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null)
Enter fullscreen mode Exit fullscreen mode

These four states powered a handleDateChange 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.

After the migration:

// After: one state, one memo
const [showCustomPicker, setShowCustomPicker] = useState<boolean>(() => {
  if (dateRange.from && dateRange.to) {
    const label = getDateRangeLabel(dateRange.from, dateRange.to, t);
    return label === getFilterDropDownLabels(t).CUSTOM_RANGE;
  }
  return false;
});

const filterRangeLabel = useMemo(() => {
  if (!dateRange.from || !dateRange.to) return getFilterDropDownLabels(t).ALL_TIME;
  return getDateRangeLabel(dateRange.from, dateRange.to, t);
}, [dateRange.from, dateRange.to, t]);
Enter fullscreen mode Exit fullscreen mode

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

The DatePicker component handles all internal state for range selection, hover highlighting, and popover management. The parent component just passes a value and onChange:

{showCustomPicker && (
  <DatePicker
    mode="analysis"
    value={dateRange.from && dateRange.to ? { from: dateRange.from, to: dateRange.to } : undefined}
    onChange={(range) => {
      if (range.from && range.to) {
        setDateRange({ from: range.from, to: range.to });
      }
    }}
    onClearDate={() => {
      setShowCustomPicker(false);
      setDateRange({ from: undefined, to: getTodayDate() });
    }}
  />
)}
Enter fullscreen mode Exit fullscreen mode

Net result: -80 lines of stateful logic, +15 lines of declarative props.


UTC Boundary Semantics

One decision worth explaining explicitly: why 23:59:59.999Z for end-of-day instead of 00:00:00.000Z of the next day?

export function toUTCEnd(cd: CalendarDate, hour = 23, minute = 59): Date {
  return new Date(Date.UTC(cd.year, cd.month - 1, cd.day, hour, minute, 59, 999));
}
Enter fullscreen mode Exit fullscreen mode

The Formbricks backend uses inclusive range queries: WHERE timestamp >= from AND timestamp <= to. An event timestamped at 2024-03-31T23:59:45.000Z falls inside a range that ends at 2024-03-31T23:59:59.999Z. It would fall outside a range that ends at 2024-04-01T00:00:00.000Z only if the query uses < instead of <=.

Using 23:59:59.999Z 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 toUTCEnd.

When time overrides are provided for analysis mode, the seconds/ms stay at their maximum values:

// User sets end time to 17:30 → stored as 17:30:59.999Z, not 17:30:00.000Z
// This ensures events at exactly 17:30:XX are captured
Enter fullscreen mode Exit fullscreen mode

What We Left Out and Why

Survey editor date inputs (validation-rule-value-input.tsx): 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.

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

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


Process Lessons

A few things from this experience that apply beyond this specific PR:

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

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

Manual testing means actually clicking. 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.

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


The Final Diff

6 commits, 9 files, 1,011 additions, 366 deletions:

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)
Enter fullscreen mode Exit fullscreen mode

All CI checks passing. Waiting on maintainer review.


The PR is open at formbricks/formbricks#8103. 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.

Top comments (0)