Timezones and Time Handling - edmozley/freeitsm GitHub Wiki

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. On Docker it comes from the TZ environment variable (TZ=Europe/London in docker-compose.yml), falling back to UTC โ€” the image replaces config.php, so until 3.0.0 a Docker install had no way to set it at all (#161).

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 one pattern never to use on a stored date: date('d M Y H:i', strtotime($utc)). Both halves run in the same zone, so it prints the stored UTC digits as if they were local time โ€” converted for nobody. It has turned up three times: email templates (#126), the "On โ€ฆ wrote:" lines in replies and handover documents (#161). Use fmt_local($utc, 'd M Y H:i'). Its twin when writing: date('โ€ฆ\Z') labels the server's local clock as UTC โ€” the Gmail importer stored every email an hour ahead in a UK summer that way. A value labelled Z must come from gmdate().

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 awkward case: a kind-1 field written from a picker

Kind 1 is described above as "written by the server", and that is true of almost every one of them. The exception is a kind-1 field whose value a person picks โ€” a ticket time entry logged after the fact, where the analyst chooses when the work happened. The field is still an absolute instant and is still converted on display; only the writing is different.

This is the one that goes wrong, because on screen it is a datetime-local box that looks exactly like a kind-2 field. Two mistakes follow, and issue #116 shipped both:

  • Posting the input's raw value. It carries no zone, and the server can only read a zone-less string as UTC โ€” so the entry lands the analyst's whole UTC offset out. Convert with inputToUTC() before sending.
  • Prefilling from new Date().getHours(). That is the browser's clock, not USER_TIMEZONE. For an analyst whose display zone differs from their machine's, the picker then disagrees with the list beside it. Prefill with nowForInput().

The second is invisible whenever the two zones agree, which is nearly always โ€” so it will not show up testing on your own machine. Set your display zone to something other than your own to see it.

A kind-2 field must not use these helpers. If the value is a plan window, a work-start or a calendar event, post the input's value as typed: converting it is the opposite bug.

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 fields the USER picks a value for (a datetime-local box whose value is
// stored as an absolute instant). Resolve USER_TIMEZONE via Intl, DST included.
nowForInput()       // 'YYYY-MM-DDTHH:MM' for now, in the analyst's DISPLAY zone
inputToUTC(value)   // a picked wall clock -> ISO-8601 UTC ('...Z'), for POSTing
utcToInput(dbStr)   // a stored UTC value -> the input's value, for EDITING one

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.

๐Ÿ”ด Never declare a top-level function fmtDate / fmtTime / fmtDateTime / parseUTCDate in a page's own script. A top-level function declaration becomes a property of window, so it does not sit beside tz.js's โ€” it replaces it for the whole page, and a wrapper that "hands over" to the shared one calls itself. Three pages did this from #1195 until 3.0.0: Assets โ†’ Users printed โ€” for every Assigned date, and a CMDB object page and the knowledge assistant recursed until the stack overflowed and then showed raw UTC. Give a page helper its own name, or keep it inside an IIFE. See Dates shown as a dash, or in server time.

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 the page lets somebody pick a value for a UTC field, the write path needs handling too, not just the display: prefill with nowForInput() / utcToInput() and send inputToUTC(). Posting the input's raw value stores the analyst's wall clock as UTC (issue #116).
  4. If a shared formatter serves more than one kind, split it.
  5. 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.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ