Skip to content

Timezones and Time Handling

Ed Mozley edited this page Jul 5, 2026 · 1 revision

Timezones & Time Handling

How FreeITSM stores and displays dates and times, and β€” crucially β€” the three kinds of stored date that each get handled differently. Read this before adding any date display to a page.

TL;DR

  • Store UTC. Server-stamped timestamps are written with UTC_TIMESTAMP().
  • Display in the analyst's chosen timezone, set per-analyst on System β†’ Preferences.
  • But not everything is a UTC instant. Scheduling values typed into a datetime-local box (a "2pm maintenance window") are naive wall-clock and are shown as typed to everyone. Date-only values (an expiry date) are never timezone-shifted. Getting the display right means knowing which kind a field is.

The per-analyst timezone

Each analyst picks a display timezone on System β†’ Preferences (reached from the account menu). It's stored as the timezone key in the user_preferences table β€” the same per-analyst store used for interface language and panel preferences. There is no global timezone setting: a single zone can't serve a team spread across countries β€” a London and a New York analyst both want the same ticket's "created" time in their local clock.

Resolution order (server side, includes/timezone.php):

  1. The logged-in analyst's timezone preference (if a valid IANA id), else
  2. date_default_timezone_get() β€” the server default set in config.php.

config.php keeps a deliberately narrow role: it's the fallback zone for any context with no logged-in analyst β€” cron jobs, the SLA engine, email polling, webhook workers β€” and the default for analysts who haven't picked one yet.

The design is side-effect free: it never calls date_default_timezone_set() to override the process zone, so server-side date math always stays on UTC. Conversion to the analyst's zone happens explicitly, at display time only.

The three kinds of stored date

This is the heart of it. Every date field is exactly one of these, and display branches on which:

Kind What it is Examples Display rule
1. Server-stamped UTC An absolute instant written by the server created_datetime, updated_datetime, received_datetime, closed_datetime, audit/history "when", comments, CAB votes, webhook deliveries, sync times Convert to the analyst's zone
2. User-entered naive wall-clock A time a person typed into a datetime-local box; stored literally, no zone change plan/work & outage windows, PIR actuals, ticket work-start, calendar events Show as typed β€” never convert
3. Date-only A bare YYYY-MM-DD, no time contract/warranty/lease/licence expiry & renewal, task/LMS due dates, morning-check dates, CI "date" properties, form date answers Never convert (would shift the day)

Why kind 2 is shown as-typed: a "2pm maintenance window" means 2pm β€” it was typed as an absolute wall-clock, not an instant. Converting it per-viewer would turn one analyst's 2pm into another's 9am and lose the shared meaning. So a naive scheduling value reads the same for every analyst. (Making scheduling truly zone-aware β€” store the instant, show each analyst their local equivalent β€” is a possible future phase; it needs a storage change and a "which zone was this entered in" rule, and touches the write paths, so it's out of scope for the display layer.)

Why kind 3 is never converted: applying a timezone to a YYYY-MM-DD value turns it into midnight-in-some-zone and can roll it to the previous/next day. A renewal date must stay on its date.

The trap: shared formatters

A single formatter often renders both kind-1 and kind-2 fields (e.g. a change's created_datetime and its work_start_datetime). You must split it β€” a zone-aware function for the UTC callers and an "as-typed" sibling for the scheduling callers β€” and route each call site by the field it renders. This is exactly where the Tickets inbox and the Change Management module were first got wrong and then corrected.

The helpers

PHP β€” includes/timezone.php

Tz::init();                       // resolve the analyst's zone for this request
Tz::current();                    // the effective IANA zone string
echo Tz::scriptTag();             // <script>window.USER_TIMEZONE = "…"</script>
fmt_local($utc, 'Y-m-d H:i');     // format a UTC value in the analyst's zone

JavaScript β€” assets/js/tz.js

Loaded on the page (after the window.USER_TIMEZONE script tag), it exposes:

parseUTCDate(str)   // parse a DB string as UTC (appends Z) -> absolute-instant Date
tzOpts(extra)       // merge {timeZone: USER_TIMEZONE} into Intl options (omitted if unset)
ymdInZone(date)     // 'YYYY-MM-DD' in the analyst zone (Today/Yesterday bucketing)
parseNaiveDate(str) // parse a naive wall-clock string from its literal components (NO zone)

Kind-1 (convert):

parseUTCDate(row.created_datetime).toLocaleString(undefined, tzOpts({ hour: '2-digit', minute: '2-digit' }))

Kind-2 (as-typed):

parseNaiveDate(row.work_start_datetime).toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' })
// note: NO tzOpts β€” the literal wall-clock is preserved

When USER_TIMEZONE is unset, tzOpts omits the zone and dates fall back to the browser's own timezone β€” so the helpers are safe everywhere.

Bootstrapping a page

Mirror tickets/index.php. Near the top:

require_once '<PFX>includes/timezone.php';
Tz::init();                      // after I18n::initFromSession()

In <head>, after the window.translations script:

<?php echo Tz::scriptTag(); ?>
<script src="<PFX>assets/js/tz.js?v=1"></script>

Every module page that renders dates does this (~130 pages). It only publishes the zone; it changes no rendering on its own.

Why calendars are different

Calendar-style views are almost entirely kind-2 and kind-3, so the default is leave them alone:

  • The calendar module stores event datetimes as naive server-local by design (they're bound straight from the datetime-local inputs, not UTC_TIMESTAMP()). Event times, all-day flags and grid placement all stay as-typed β€” and crucially, both the time labels and the grid cell/hour placement stay naive, so they're internally consistent (no "the label says 2pm but it's plotted in the 1pm row").
  • Morning Checks keys off bare YYYY-MM-DD check dates and buckets its 30-day trend chart by day β€” all kind-3, never shifted.
  • Report date-range filters, chart x-axis day-buckets and GROUP BY DATE(...) aggregation are date-only logic β€” leave them; shifting them moves counts between days.

Storage nuances to know

  • system-wiki runs on SQL Server and stamps with GETDATE() (server-local, not UTC). Its timestamps are therefore naive-local and are left unconverted.
  • A few CURRENT_TIMESTAMP column defaults (e.g. calendar created_at) write in the MySQL session zone rather than an explicit UTC_TIMESTAMP(). Where these aren't user-facing they're left as-is; prefer UTC_TIMESTAMP() for any new server-stamped column so it's unambiguously kind-1.
  • last_seen in the software module is collapsed to a date-only string by its API before it reaches the client β€” so it's effectively kind-3 today. Per-user local last_seen would require the API to return the raw UTC datetime first.

Checklist for adding a date to a page

  1. Ensure the page has the bootstrap (Tz::init() + Tz::scriptTag() + tz.js).
  2. Decide the field's kind:
    • Server-stamped UTC β†’ parseUTCDate + tzOpts (JS) / fmt_local (PHP).
    • Naive datetime-local scheduling value β†’ parseNaiveDate, no tzOpts.
    • Bare YYYY-MM-DD β†’ render as-is.
  3. If a shared formatter serves more than one kind, split it.
  4. Never timezone-convert values used in SQL/WHERE/aggregation/duration math β€” only human-readable display.

See also the local design note docs/design/timezone-per-user.md in the app repo.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally