How to Handle Time Zones in an AI-Built App

Handling time zones in an AI-built app comes down to four words: store UTC, render local. Almost every time-zone bug in a generated codebase is a violation of one half of it, and there are three violations that show up over and over.

Steve Jefferson
Steve Jefferson
Developer Advocate
21 August 20261 min read

Handling time zones in an AI-built app comes down to four words: store UTC, render local. Almost every time-zone bug in a generated codebase is a violation of one half of it, and there are three violations that show up over and over.

They are worth knowing by shape, because they do not produce errors. They produce a booking at the wrong hour, twice a year, for some of your users.

Mistake one: the column has no time zone

Ask for a bookings table and you will frequently get this:

sql
-- generated
create table bookings (
  id uuid primary key default gen_random_uuid(),
  starts_at timestamp not null   -- no time zone
);

timestamp without a zone stores wall-clock digits and nothing else. Two rows reading 14:00 might be nine hours apart and the database cannot tell you, because the information was discarded at write time. It is not recoverable later.

sql
-- correct
create table bookings (
  id uuid primary key default gen_random_uuid(),
  starts_at timestamptz not null,
  -- keep the zone the user chose, separately, for recurrence
  tz text not null default 'UTC'
);

The second column is not redundant. timestamptz stores an absolute instant, which is exactly right for a one-off event and exactly wrong for a repeating one, for reasons in mistake three.

If you are picking a store now rather than fixing one, the trade-offs are in how to choose a database for an AI-built app.

Mistake two: formatting on the server

This is the one that produces a support ticket from exactly one customer, in one country, and cannot be reproduced at your desk:

javascript
// generated: formats in the server's zone
const label = booking.startsAt.toLocaleString('en-GB');
return { label };

The server has a zone. In containers it is usually UTC, on a laptop it is wherever you are, and in a serverless runtime it may vary by region. Whatever it is, it is not the user's.

javascript
// correct: send an instant, format in the browser
return { startsAt: booking.startsAt.toISOString() };

// client
new Intl.DateTimeFormat(undefined, {
  dateStyle: 'medium',
  timeStyle: 'short',
}).format(new Date(startsAt));

Passing undefined as the locale makes Intl use the browser's own settings, which are the user's actual preferences rather than your guess at them. The exception is email and PDF, where there is no browser: those need the recipient's stored zone passed explicitly as timeZone.

If your app also renders in more than one language, the same boundary applies to dates as to strings, and it is covered in how to add multi-language support to an AI-built app.

Mistake three: DST arithmetic on recurring events

The subtle one, and the reason the extra tz column exists.

javascript
// generated: adds a fixed number of milliseconds
const nextWeek = new Date(current.getTime() + 7 * 24 * 60 * 60 * 1000);

Seven days is not always 604,800,000 milliseconds. In a zone that observes daylight saving, one week a year is an hour shorter and another is an hour longer. A weekly 09:00 standup advanced this way silently becomes 08:00 or 10:00 on the last Sunday of March, and stays wrong until someone notices.

The fix is to do the arithmetic in the user's zone, on calendar units, then convert back to an instant:

javascript
// correct: calendar arithmetic in the user's zone
import { addWeeks } from 'date-fns';
import { toZonedTime, fromZonedTime } from 'date-fns-tz';

const local = toZonedTime(current, booking.tz);   // e.g. Europe/Zurich
const nextLocal = addWeeks(local, 1);             // 09:00 stays 09:00
const nextInstant = fromZonedTime(nextLocal, booking.tz);

Store Europe/Zurich, never CET and never +01:00. A fixed offset is wrong for half the year, and abbreviations are ambiguous: CST is used for at least three different zones. The canonical identifiers come from the IANA time zone database, which is updated several times a year as governments change their rules.

Recurrence usually runs on a scheduler, and scheduled work has its own set of traps: see how to add background jobs to an AI-built app.

One test that catches all three

Run this against a zone with daylight saving, from a process whose own zone is something else entirely. It fails on any of the three mistakes above.

javascript
// TZ=Asia/Kolkata npm test
// Asia/Kolkata has no DST and a :30 offset, so it exposes
// any accidental dependence on the server's own clock.

test('weekly 09:00 in Zurich survives the DST boundary', async () => {
  const tz = 'Europe/Zurich';

  // Friday 27 March 2026, 09:00 local, before the spring change
  const first = fromZonedTime(new Date('2026-03-27T09:00:00'), tz);
  const booking = await createBooking({ startsAt: first, tz, repeat: 'weekly' });

  const next = await nextOccurrence(booking);

  // Europe/Zurich moves to summer time on 29 March 2026
  const localHour = formatInTimeZone(next, tz, 'yyyy-MM-dd HH:mm');
  expect(localHour).toBe('2026-04-03 09:00');   // still 09:00 local

  // and the stored instant must have moved by 6 days 23 hours, not 7 days
  const deltaHours = (next.getTime() - first.getTime()) / 3_600_000;
  expect(deltaHours).toBe(167);
});

That final assertion is the whole point. Seven calendar days across a spring transition is 167 hours, not 168. Code that adds a fixed week returns 168 and fails here, which is precisely what you want it to do.

Set TZ explicitly in CI so this runs somewhere that is not UTC. A test suite that only ever runs in UTC cannot catch mistake two at all. Where that fits in a broader pre-launch pass is in how to test an AI-built app before launch.

A time zone checklist for an AI-built app

  • Every timestamp column is timestamptz or the equivalent, never a naked local time.

  • Users have a stored zone, defaulted from Intl.DateTimeFormat().resolvedOptions().timeZone at signup and editable afterwards.

  • APIs send ISO 8601 instants with an offset. Clients format them.

  • Recurring events store an IANA zone identifier and do calendar arithmetic in it.

  • Emails and PDFs format explicitly in the recipient's stored zone.

  • CI runs with TZ set to something that is not UTC.

If you are storing a future local time whose zone rules might change before it arrives, the extended format in RFC 9557 lets you carry the identifier alongside the instant instead of guessing later.

Frequently asked questions

Should I store dates in UTC or local time?

Store the instant in UTC. Separately store the IANA zone the user chose if the event repeats, because recurrence has to be calculated in local calendar terms rather than in elapsed time.

Why does my scheduled job run an hour early twice a year?

Because the next run is being computed by adding a fixed number of milliseconds rather than a calendar unit in the user's zone. Across a daylight saving transition, a day is not always 24 hours.

Can I just store the UTC offset instead of the zone name?

No. An offset like +01:00 is correct for part of the year only, and zone abbreviations are ambiguous. Store an IANA identifier such as Europe/Zurich.

How do I get the user's time zone?

In a browser, Intl.DateTimeFormat().resolvedOptions().timeZone returns an IANA identifier. Capture it at signup as a default, and let the user change it, since travellers and people who moved will need to.

For the parts of the build that come before any of this, start at how to build an app with AI.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.