Skip to content

Date and Time Formats Developer Guide

Ed Mozley edited this page Aug 25, 2026 · 1 revision

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.


1. Two settings, two questions

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.


2. PHP

The display family

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.

A typical call site

<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
];

❌ What not to write

// ❌ 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');

3. JavaScript

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'

A typical call site

rows.map(r => `
    <tr>
        <td>${esc(r.subject)}</td>
        <td>${esc(fmtDateTime(r.created_datetime))}</td>
    </tr>
`).join('');

❌ What not to write

// ❌ 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);

4. UTC instants versus naive wall-clock values

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' everywhere

The 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()]);

5. Display formats versus machine formats

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.


6. Bespoke shapes

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.

Tokens

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.

JavaScript

// 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

PHP

$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.


7. Wiring up a page

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. Editing assets/js/tz.js means bumping ?v= on every page that loads it. A stale copy on one page silently keeps the old behaviour there.


8. Adding a new format

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.


9. Month names, and the one trap

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', …],
],

The in-date form

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/en must never define months_in_date. English does not inflect, so it would only duplicate months β€” 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 as 5 JanuaryX 2026.

This is why the i18n audit reports months_in_date as an EXTRA key in ru, pl and uk. That is correct, not drift.


10. Finding regressions

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:

  1. Number formatting β€” n.toLocaleString() for thousands separators. A different concern.
  2. 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 -u

11. Proving a change

The 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_FORMAT found 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.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally