Timezones and Time Handling - edmozley/freeitsm GitHub Wiki
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.
-
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-localbox (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.
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):
- The logged-in analyst's
timezonepreference (if a valid IANA id), else -
date_default_timezone_get()โ the server default set inconfig.php. On Docker it comes from theTZenvironment variable (TZ=Europe/Londonindocker-compose.yml), falling back to UTC โ the image replacesconfig.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). Usefmt_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 fromgmdate().
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.
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, notUSER_TIMEZONE. For an analyst whose display zone differs from their machine's, the picker then disagrees with the list beside it. Prefill withnowForInput().
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.
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.
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 zoneLoaded 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 oneKind-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 preservedWhen 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/parseUTCDatein a page's own script. A top-level function declaration becomes a property ofwindow, 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.
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.
Calendar-style views are almost entirely kind-2 and kind-3, so the default is leave them alone:
- The
calendarmodule stores event datetimes as naive server-local by design (they're bound straight from thedatetime-localinputs, notUTC_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-DDcheck 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.
-
system-wikiruns on SQL Server and stamps withGETDATE()(server-local, not UTC). Its timestamps are therefore naive-local and are left unconverted. - A few
CURRENT_TIMESTAMPcolumn defaults (e.g. calendarcreated_at) write in the MySQL session zone rather than an explicitUTC_TIMESTAMP(). Where these aren't user-facing they're left as-is; preferUTC_TIMESTAMP()for any new server-stamped column so it's unambiguously kind-1. -
last_seenin 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 locallast_seenwould require the API to return the raw UTC datetime first.
- Ensure the page has the bootstrap (
Tz::init()+Tz::scriptTag()+tz.js). - Decide the field's kind:
- Server-stamped UTC โ
parseUTCDate+tzOpts(JS) /fmt_local(PHP). - Naive
datetime-localscheduling value โparseNaiveDate, notzOpts. - Bare
YYYY-MM-DDโ render as-is.
- Server-stamped UTC โ
- 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 sendinputToUTC(). Posting the input's raw value stores the analyst's wall clock as UTC (issue #116). - If a shared formatter serves more than one kind, split it.
- 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.