-
Notifications
You must be signed in to change notification settings - Fork 16
Date and Time Formats Developer Guide
How to render a date or a time anywhere in FreeITSM, in PHP or in JavaScript, so that it follows the analyst's chosen format and timezone.
The one-line version: never call date(), ->format(), toLocaleDateString() or Intl.DateTimeFormat for anything a person will read. Call one of the fmt_* / fmt* helpers below.
See Date and Time Formats for what the feature does, and Timezones and Time Handling for the zone half.
They are separate on purpose and must never be conflated.
| Question | Where it lives | |
|---|---|---|
Tz |
Which instant? 14:30 vs 15:30
|
includes/timezone.php, assets/js/tz.js
|
DateFmt |
What does it look like? 14:30 vs 2:30 PM
|
the same two files |
Tz converts the stored UTC value into the analyst's zone. DateFmt then decides how to write it down. Changing one must never move the other.
Every one of these takes a UTC datetime string as stored in the database, converts it to the analyst's display zone, and renders it in their chosen format. All return '' for null or ''.
require_once __DIR__ . '/includes/timezone.php';
Tz::init(); // once per page, after session_start()
$utc = '2026-08-05 13:07:00'; // as it comes out of the database
fmt_date($utc); // '05 Aug 2026' β the whole date
fmt_time($utc); // '14:07' β just the clock
fmt_datetime($utc); // '05 Aug 2026 14:07' β both
fmt_day_month($utc); // '05 Aug' β compact, year implied
fmt_month_year($utc); // 'August 2026' β calendar headings
fmt_weekday($utc); // 'Wednesday'
fmt_weekday($utc, true); // 'Wed'Those outputs are for an analyst on Europe/London with the default format. The same call for an analyst who picked dmy_dot and 12h returns 05.08.2026 2:07 PM, with no change to the calling code.
<td><?= htmlspecialchars(fmt_datetime($row['created_datetime'])) ?></td>// Building an array for JSON β format on the way out, never on the way in.
$out[] = [
'id' => (int)$r['id'],
'created' => fmt_datetime($r['created_datetime']), // for display
'sort' => $r['created_datetime'], // raw UTC, for sorting
];// β date() is not locale-aware and knows nothing about the analyst's zone.
echo date('d M Y H:i', strtotime($row['created_datetime']));
// β 'M' emits an English month name in every language, forever.
echo (new DateTime($utc))->format('j M Y');
// β Hardcodes a country's convention where a setting belongs.
echo $dt->format('d/m/Y');assets/js/tz.js must be loaded before any script that formats a date, and the page must emit Tz::scriptTag() (see Β§7).
Every function accepts either a database datetime string or a Date, and returns '' for empty input β so you rarely need to parse anything yourself.
const utc = '2026-08-05 13:07:00'; // straight from the API response
fmtDate(utc); // '05 Aug 2026'
fmtTime(utc); // '14:07'
fmtDateTime(utc); // '05 Aug 2026 14:07'
fmtDayMonth(utc); // '05 Aug'
fmtMonthYear(utc); // 'August 2026'
fmtWeekday(utc); // 'Wednesday'
fmtWeekday(utc, true); // 'Wed'rows.map(r => `
<tr>
<td>${esc(r.subject)}</td>
<td>${esc(fmtDateTime(r.created_datetime))}</td>
</tr>
`).join('');// β Names a country in the code. No setting can reach it. This is the
// original bug from issue #105, 67 times over.
d.toLocaleDateString('en-GB', tzOpts({ day: '2-digit', month: 'short', year: 'numeric' }));
// β Follows the BROWSER, not FreeITSM. Right by luck on a matching machine,
// wrong the moment the two disagree. This is what Watchtower used to do.
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// β Same bug, different spelling β and easy to miss when grepping.
new Intl.DateTimeFormat('en-US', { β¦ }).formatToParts(d);
// β No 'Z', so a UTC value from the database is read as browser-local time.
// This is not a formatting bug, it is the WRONG INSTANT.
new Date(dbString);This is the distinction that matters most, and getting it wrong shifts a time by hours.
| What it is | Examples | Use | |
|---|---|---|---|
| UTC instant | A moment, stamped by the server |
created_datetime, modified_datetime, last_login
|
fmtDate, fmtTime, fmtDateTime, β¦ |
| Naive wall-clock | What somebody typed, with no zone at all | change work windows, ticket work-start, task due dates, rota days |
fmtNaiveDate, fmtNaiveTime, fmtNaiveDateTime, β¦ |
A naive value must read the same for every analyst. If a change window says 2pm, it says 2pm in London and in Tokyo β converting it would be actively wrong.
// Server-stamped: convert to the analyst's zone.
fmtDateTime(ticket.created_datetime); // '05 Aug 2026 14:07' in London
// '05 Aug 2026 22:07' in Tokyo
// User-typed schedule: show exactly as entered, for everyone.
fmtNaiveDateTime(change.work_start_datetime); // '05 Aug 2026 09:00' everywhereThe full naive family mirrors the other one exactly:
fmtNaiveDate(v); fmtNaiveTime(v); fmtNaiveDateTime(v);
fmtNaiveDayMonth(v); fmtNaiveMonthYear(v); fmtNaiveWeekday(v, short);Date-only values are naive. A task due date of 2026-08-05 has no time and no zone:
// β
fmtNaiveDate('2026-08-05'); // '05 Aug 2026' for everybody
// β new Date('2026-08-05') parses as UTC MIDNIGHT, which reads back as
// 4 August for anyone west of Greenwich.
fmtDate(new Date('2026-08-05'));parseNaiveDate() handles both YYYY-MM-DD HH:MM and bare YYYY-MM-DD, building the date from its literal components either way.
On the PHP side there is no separate naive family β pass the value through DateFmt::render() directly, because fmt_* always converts from UTC:
// A naive wall-clock value, rendered without any zone conversion.
$dt = new DateTime($change['work_start_datetime']); // no timezone argument
echo DateFmt::render($dt, DateFmt::DATE_TEMPLATES[DateFmt::dateKey()]);A format the analyst chose must never reach a value that is compared, sorted, stored, or round-tripped. If it did, changing a preference would reorder tables and break date pickers.
| Purpose | Use | Never use |
|---|---|---|
| Something a person reads |
fmt_datetime() / fmtDateTime()
|
β |
| A sort key, a bucket key |
ymdInZone(d) β pinned to ISO |
any fmt*
|
<input type="date"> |
fmt_local($utc, 'Y-m-d') |
any fmt*
|
| A SQL value, an ICS field | fmt_local($utc, 'Y-m-d H:i:s') |
any fmt*
|
// β
Today/Yesterday bucketing β both sides are ISO, so they compare.
const isToday = ymdInZone(d) === ymdInZone(new Date());
// β Would stop matching the moment somebody picks a different format.
const isToday = fmtDate(d) === fmtDate(new Date());// β
An explicit pattern. Never consults the setting.
$value = fmt_local($row['created_datetime'], 'Y-m-d');
// β
Display, in the analyst's format.
$label = fmt_date($row['created_datetime']);fmt_local($utc, $pattern) is the deliberate escape hatch: it converts the zone but takes an explicit date() pattern, so it is the right tool for machine output and the wrong tool for anything on screen.
A few places need a shape none of the helpers produce β a calendar week title that compresses to 5 β 11 May 2026, or a rota column heading that is just a weekday and a day number. The shape there is dictated by the layout, not by preference.
Use the token templates rather than reaching for toLocale*. Month and weekday names still follow the interface language.
| Token | Meaning | Example |
|---|---|---|
D |
day, no padding | 5 |
DD |
day, 2 digits | 05 |
MM |
month, 2 digits | 08 |
MON |
short month name |
Aug / MΓ€r
|
MONTH |
full month name |
August / MΓ€rz
|
YY |
2-digit year | 26 |
YYYY |
4-digit year | 2026 |
HH |
hour, 24-clock | 14 |
h |
hour, 12-clock | 2 |
mi |
minute | 07 |
A |
AM / PM | PM |
Anything that is not a token is a literal.
// A week title that compresses when it can.
const start = fmtNaiveTemplate(weekStart,
sameMonth ? 'D' : (sameYear ? 'D MON' : 'D MON YYYY'));
const end = fmtNaiveTemplate(weekEnd, 'D MON YYYY');
// -> '5 β 11 May 2026'
fmtTemplate(utcValue, 'MONTH YYYY'); // a UTC instant, custom shape
fmtNaiveTemplate(dateOnly, 'D MONTH'); // a naive value, custom shape$dt = new DateTime('now', new DateTimeZone(Tz::current()));
DateFmt::render($dt, 'D MONTH YYYY'); // '5 August 2026'
DateFmt::render($dt, 'MON D, YYYY'); // 'Aug 5, 2026'
DateFmt::render($dt, 'HH:mi'); // '14:07'
// Or render the analyst's CHOSEN template explicitly:
DateFmt::render($dt, DateFmt::DATE_TEMPLATES[DateFmt::dateKey()]);
DateFmt::render($dt, DateFmt::TIME_TEMPLATES[DateFmt::timeKey()]);Both renderers consume the same template strings, which is what guarantees PHP and JavaScript produce identical output.
A page that renders any date needs three things. Miss one and the failure is silent.
<?php
session_start();
require_once '../config.php';
require_once '../includes/i18n.php';
require_once '../includes/timezone.php'; // 1. the class
I18n::initFromSession();
Tz::init(); // 2. resolve zone AND format
?>
<head>
β¦
<?php echo Tz::scriptTag(); ?> <!-- 3. publish to the browser -->
<script src="../assets/js/tz.js?v=4"></script> <!-- 4. the JS helpers -->
</head>Tz::init() resolves the format too, and hands its database connection over so there is no second connect. Tz::scriptTag() publishes everything the browser needs:
window.USER_TIMEZONE = "Europe/London";
window.DATE_FORMAT = {
dateTemplate: "DD MON YYYY",
timeTemplate: "HH:mi",
dayMonthTemplate: "DD MON",
months: ["January", β¦],
monthsInDate: ["January", β¦], // differs only where the language inflects
monthsShort: ["Jan", β¦],
weekdays: ["Monday", β¦],
weekdaysShort: ["Mon", β¦]
};The names are published server-side rather than read from window.translations, because that object is namespace-scoped per page β a page that does not export common would otherwise render months as dotted key paths.
β οΈ Bump the cache-buster. Editingassets/js/tz.jsmeans bumping?v=on every page that loads it. A stale copy on one page silently keeps the old behaviour there.
One place. Both renderers pick it up automatically.
// includes/timezone.php
const DATE_TEMPLATES = [
'd_mon_y' => 'DD MON YYYY',
β¦
'dmy_dot' => 'DD.MM.YYYY',
'mon_yyyy_d'=> 'MON YYYY, D', // β new
];
const DAY_MONTH_TEMPLATES = [
β¦
'mon_yyyy_d' => 'MON D', // β its year-less companion, also required
];Add a matching label nowhere β the settings page renders each option as a live example, so a new format explains itself with no new translation strings.
Month and weekday names live in lang/<loc>/common.php under calendar:
'calendar' => [
'months' => ['january' => 'Januar', β¦],
'months_short' => ['january' => 'Jan', β¦],
'weekdays' => ['monday' => 'Montag', β¦],
'weekdays_short' => ['monday' => 'Mo', β¦],
],Some languages inflect the month when a day number sits beside it:
| Standing alone | Inside a date | |
|---|---|---|
| Russian | ΠΌΠ°ΡΡ 2026 |
5 ΠΌΠ°ΡΡΠ° 2026 |
| Polish | marzec 2026 |
5 marca 2026 |
| Ukrainian | Π±Π΅ΡΠ΅Π·Π΅Π½Ρ 2026 |
5 Π±Π΅ΡΠ΅Π·Π½Ρ 2026 |
Those locales add an optional block:
'months_in_date' => ['january' => 'ΡΠ½Π²Π°ΡΡ', β¦],Both renderers select it automatically when the template contains a day token, so MONTH YYYY stays nominative and D MONTH YYYY inflects. Languages that do not need it simply omit the block.
π΄
lang/enmust never definemonths_in_date. English does not inflect, so it would only duplicatemonthsβ but its presence makes the "is this defined?" probe succeed for every locale, and the English fallback then feeds English month names into every other language's dates. Proven: adding it renders German as5 JanuaryX 2026.This is why the i18n audit reports
months_in_dateas an EXTRA key inru,planduk. That is correct, not drift.
The audit that matters greps for all three spellings, because two separate sweeps each missed one:
grep -rn "toLocaleDateString\|toLocaleTimeString\|toLocaleString(\|Intl.DateTimeFormat" \
--include=*.php --include=*.js . \
| grep -v "min.js\|/vendor/\|assets/js/tz.js"Anything that survives should be one of exactly two things:
-
Number formatting β
n.toLocaleString()for thousands separators. A different concern. -
browser-extension/popup.jsβ a documented carve-out. No PHP, no session, nothing to read the setting from.
A date in that output is a bug.
Two more checks worth running before shipping a change to this area:
# Every page that calls a formatter must actually load tz.js.
grep -rl "fmtDate(\|fmtDateTime(\|fmtNaive" --include=*.php . | while read p; do
grep -q "js/tz\.js" "$p" || echo "MISSING tz.js: $p"
done
# No page may reference two different versions of the same script.
grep -rhno "js/tz\.js?v=[0-9]*" --include=*.php . | sed 's|.*?v=||' | sort -uThe two renderers are independent implementations of one specification, so they are tested against each other: three awkward instants (a BST afternoon, an after-midnight winter time, and noon) rendered through every template in both PHP and a real browser, then compared byte for byte. 69 cases per language.
Noon and midnight are in there deliberately β 12:00 PM and 12:30 AM are where hand-rolled 12-hour code goes wrong, because 0 and 12 are the two hours that do not follow the obvious rule.
Two habits worth copying from this work:
-
Drive the real page, not just the API. A settings page once published the default format to the browser while the database said otherwise. The server-rendered half was correct, so the page looked perfect. Only loading it in a browser and reading
window.DATE_FORMATfound it. - Pair every check with a negative control. Assert that the bucket key returns the same ISO string before and after a format switch β otherwise you have proved only that something changed, not that the right thing changed.
- Date and Time Formats β what the feature does
- Timezones and Time Handling
- Internationalisation
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
- π Date & Time Formats
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π§° Mobile: Techniques & Tricks
-
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
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- 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)