-
Notifications
You must be signed in to change notification settings - Fork 15
Timezones and 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.
-
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.
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.
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.
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 (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.
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 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.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)