I want to start by saying this: if you have ever stared at an analytics chart that looked just slightly wrong and could not figure out why, this post is for you. Not because you made a mistake, but because this class of bug is genuinely quiet. It does not crash anything. No error is thrown. The data comes back, the chart renders, and everything looks fine — until someone in a different timezone notices that the numbers do not quite add up.

That is exactly where this story begins.

A User Reports Something Strange

The report came in simply enough. A user managing a project based in New Zealand opened the analytics dashboard, selected "Yesterday," and noticed that some of the data did not look right. A portion of the results seemed to belong to today, and some of yesterday's activity appeared to be missing entirely.

My first instinct, honestly, was to question the report. Data issues are often perception issues — maybe the user was looking at the wrong filter, or the timezone display was misleading them. But when I sat down and looked more carefully, the pattern was undeniable. For this particular project, the "Yesterday" filter was consistently off by 13 hours.

Thirteen hours. That is not a rounding error. That is a timezone offset.

Setting the Scene

The analytics dashboard in question lets users filter data by date presets: Today, Yesterday, Last 7 Days, This Week, This Month, and so on. Each project in the system can have its own configured timezone. The database stores all timestamps in UTC, which is the correct and standard approach for any multi-tenant application. The frontend is responsible for translating what "yesterday" means in a given timezone into the actual UTC boundaries that get sent to the API.

The component handling this translation was DateFilterContext.tsx. It had been working without complaints for a long time — for most projects, because most projects were configured with UTC or close-to-UTC timezones. The New Zealand project, sitting at UTC+13 during New Zealand Daylight Time, pushed the offset far enough to make the problem impossible to ignore.

Finding the Root Cause

I pulled up DateFilterContext.tsx and traced the flow for "Yesterday."

The code did something smart: it used a utility called getDateInTimezone() to correctly identify what calendar date "yesterday" was in the project's timezone. So if today is March 31 in Auckland, it correctly identified that yesterday was March 30. That part worked fine.

But then it made a quiet, costly assumption. Once it had the calendar date, it constructed the start and end of that day as UTC midnight to UTC midnight. Like this:

startDate = 2026-03-30T00:00:00.000Z
endDate   = 2026-03-30T23:59:59.999Z

These are UTC boundaries. But "March 30 in Auckland" does not start at UTC midnight. It starts 13 hours earlier — at 2026-03-29T11:00:00.000Z — because Auckland is UTC+13.

So the query was asking the database for data between UTC midnight and UTC midnight on March 30. What it should have been asking for was data between 11:00 UTC on March 29 and 10:59 UTC on March 30 — the actual window that corresponds to March 30 in New Zealand.

The result was exactly what the user saw: 13 hours of March 31 Auckland data was being included (because UTC midnight March 30 to UTC midnight March 31 overlaps with March 31 in NZ), and 13 hours of actual March 30 Auckland data was being excluded (the early morning hours that already passed in UTC before Auckland's day even started).

The code had the right date. It just forgot to account for what time that date actually begins and ends when you are 13 hours ahead of UTC.

Why This Kind of Bug Is So Easy to Miss

I do not want to gloss over this, because I think it is worth pausing on.

The bug did not appear for projects in UTC, or in timezones close to UTC. It appeared gradually the further a project's timezone was from UTC. For US Eastern (UTC-5), the shift would be 5 hours — noticeable if you looked carefully, but easy to attribute to other factors. For New Zealand, the 13-hour shift was dramatic enough that it crossed calendar day boundaries, making the error obvious.

This is the nature of timezone bugs. They are conditional. They hide behind the fact that most of your users, most of the time, are probably in a timezone that is close enough to UTC that the error stays below the threshold of suspicion. It takes one user, in one timezone, paying close enough attention to surface it.

If you work on software that involves dates, time ranges, or any kind of "show me what happened on this day" functionality — this pattern is worth keeping in mind.

Designing the Fix

Once the root cause was clear, I had a decision to make about where to fix it.

The two obvious options were:

Option A: Fix it in the frontend. The DateFilterContext already had access to the project's timezone. Before sending date boundaries to the API, convert them from "midnight in the project's timezone" to the correct UTC equivalent.

Option B: Fix it in the API. Accept a timezone parameter in every analytics endpoint, and let the server-side do the conversion.

Option B sounds appealing in theory — centralizing timezone logic on the server is clean. But in practice, it would have meant changing every analytics API endpoint, updating every API consumer to send the timezone parameter, and hoping nothing was missed. The API currently received UTC Date objects and queried the database with them. It had no timezone context, and that was intentional.

Option A was the right call. The frontend was the place where the user's intent — "show me yesterday in my project's timezone" — was being translated into a query. That is exactly where the timezone awareness belonged. The fix should live at the boundary between intent and execution.

Writing the Solution

The fix needed to do one thing: take a calendar date (like March 30) and produce the correct UTC timestamps for the start and end of that day in a given IANA timezone.

To do that, I needed to know the UTC offset for that timezone on that specific date. I wrote a small utility function to calculate it:

function getTimezoneOffsetMs(date: Date, timezone: string): number {
  const formatter = new Intl.DateTimeFormat('en-US', {
    timeZone: timezone,
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
    hour12: false,
  });
  const parts = formatter.formatToParts(date);
  const get = (type: string) =>
    parseInt(parts.find(p => p.type === type)?.value || '0');
  const tzDate = new Date(Date.UTC(
    get('year'), get('month') - 1, get('day'),
    get('hour') === 24 ? 0 : get('hour'), get('minute'), get('second')
  ));
  return tzDate.getTime() - date.getTime();
}

What this does is ask Intl.DateTimeFormat — a browser built-in — what a given UTC instant looks like when expressed in the target timezone. By comparing that local representation back to the original UTC time, we get the offset in milliseconds. It is a small trick, but it is reliable and requires no external dependencies.

With that in hand, the startOfDay function became:

function startOfDay(date: Date, timezone?: string): Date {
  const midnightUTC = new Date(Date.UTC(
    date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(),
    0, 0, 0, 0
  ));
  if (!timezone || timezone === 'UTC') return midnightUTC;
  const offsetMs = getTimezoneOffsetMs(midnightUTC, timezone);
  return new Date(midnightUTC.getTime() - offsetMs);
}

The offset is subtracted because for timezones ahead of UTC, the offset is positive — Auckland at UTC+13 has a +13-hour offset — and midnight in Auckland comes before UTC midnight. So we shift backwards by that amount.

The same pattern was applied to endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfQuarter, endOfQuarter, startOfYear, and endOfYear. Every function that constructs a day boundary now accepts an optional timezone parameter and applies the offset when it is provided.

A Note on Using Intl.DateTimeFormat

You might wonder why I used the browser's built-in Intl.DateTimeFormat instead of a popular library like date-fns-tz.

Honestly, both would have worked. But Intl.DateTimeFormat has a few properties that made it the right fit here:

  • Zero bundle size impact. It is already in the browser and in Node.js. No new dependency, no added kilobytes.
  • It uses the IANA timezone database internally. The same database that date-fns-tz uses. So the results are consistent.
  • It handles DST automatically. This is the important one.

Daylight saving time means that a timezone's UTC offset is not constant throughout the year. New Zealand is UTC+13 in summer (NZDT) and UTC+12 in winter (NZST). Because getTimezoneOffsetMs calculates the offset for the specific date in question — not a fixed offset for the timezone — it automatically picks up the right value regardless of whether DST is in effect. There is no manual DST logic to maintain.

The one tradeoff is readability. date-fns-tz's zonedTimeToUtc() is arguably more expressive than computing an offset via formatToParts. But given the project already used Intl.DateTimeFormat in similar places, staying consistent felt right.

Verifying the Fix

The clearest way to verify the fix was to check the exact timestamps being produced for a known input.

Project timezone: Pacific/Auckland (UTC+13 during NZDT) Preset: "Yesterday" when today is March 31, 2026

Before the fix After the fix startDate 2026-03-30T00:00:00.000Z 2026-03-29T11:00:00.000Z endDate 2026-03-30T23:59:59.999Z 2026-03-30T10:59:59.999Z Window 24h of the wrong UTC day 24h of the correct NZ day

The window is still exactly 24 hours. It is just anchored to the right point in time now.

For projects configured with UTC — which is the default — nothing changes. The timezone parameter is omitted, and the functions behave exactly as they did before.

What This Bug Taught Me

I think the most useful thing I can share is not the code itself, but the pattern that led to the bug.

The mistake was a small, hidden assumption: that "the start of a day" meant "UTC midnight." For most of the users the system was built with in mind, that assumption held. UTC and UTC-adjacent timezones are the most common in the parts of the world where this kind of SaaS product typically finds its first users. The assumption went untested not because anyone was careless, but because the failing case never appeared.

Timezone bugs tend to work like that. They are not loud. They do not cause outages. They cause a subset of your users — the ones furthest from UTC, or the ones paying close enough attention to notice — to quietly get the wrong answer. And they can live in a codebase for a long time before someone connects the dots.

If your application handles date ranges, filters, or any concept of "today" or "this week" — and you support users in different timezones — it is worth asking: where exactly does the conversion from local calendar date to UTC timestamp happen? Is it doing what you think it is?

Usually, the answer is yes. But it is worth checking.

Closing Thought

I am writing this because when I was trying to understand this bug, I found that timezone issues in frontend date filtering are not particularly well-documented. There are plenty of articles about how UTC works, or how DST works, or which libraries to use for timezone-aware date math. But the specific pattern of "I have the right date but the wrong UTC boundaries" was something I had to reason through mostly on my own.

I hope this saves someone a few hours.

If you have hit a similar issue, or if something here is unclear, I would genuinely like to hear about it.