| ← Back to JavaScript Development | ← Back to Table of Contents |
10.3 Timezone handling
KMP stores datetimes and runs PHP/database connections in UTC. User-facing
calendar values are converted at the application boundary with IANA timezone
identifiers such as America/Chicago. Never store a formatted local display
string as the canonical instant.
Resolution rules
For member-oriented display, App\KMP\TimezoneHelper::getUserTimezone()
resolves:
- the member’s valid
timezone; - an explicitly supplied valid default;
- the tenant’s
KMP.DefaultTimezoneapplication setting; then UTC.
For a Gathering context,
TimezoneHelper::getGatheringTimezone() resolves:
- the Gathering’s valid
timezone; - the member’s timezone;
- the tenant application default; then
UTC.
Use the Gathering context for schedules, public event pages, attendance, iCal, and other location-based event times. Use member context for personal/audit times. Date-only domain values such as expiration dates generally should not shift calendar days through unnecessary instant conversion.
KMP.DefaultTimezone is tenant application data. The PHP helper caches it
within the current process until clearCache(); long-running code that switches
tenant contexts in one process must clear that cache at the tenant boundary.
Owning components
| Layer | Source | Responsibility |
|---|---|---|
| Domain utility | app/src/KMP/TimezoneHelper.php |
Resolution, validation, conversion, lists, offsets, and base formatting |
| View helper | app/src/View/Helper/TimezoneHelper.php |
Template-friendly display, localized formats, ranges, and input values |
| Client utility | app/assets/js/timezone-utils.js |
Browser detection, Intl formatting, and datetime-local conversion |
| Stimulus enhancement | app/assets/js/controllers/timezone-input-controller.js |
Explicit form-scope UTC/local conversion |
| Settings | tenant AppSettings, key KMP.DefaultTimezone |
Tenant display fallback |
| Entity fields | members.timezone, gatherings.timezone |
Personal and event-location overrides |
Display in templates
AppView loads the Timezone helper. Its format() method accepts flexible
arguments for compatibility; prefer a clear format plus explicit context.
<?= h($this->Timezone->format(
$gathering->start_date,
'F j, Y g:i A',
true,
null,
$gathering,
)) ?>
Common methods:
<?= h($this->Timezone->format($record->created, 'F j, Y g:i A', true)) ?>
<?= h($this->Timezone->date($warrant->expires_on, 'M j, Y')) ?>
<?= h($this->Timezone->time($gathering->start_date, null, null, $gathering)) ?>
<?= h($this->Timezone->forInput(
$gathering->start_date,
null,
$currentUser,
$gathering,
)) ?>
format() can also receive IntlDateFormatter date/time styles for localized
output. Escape returned text. When the timezone is not obvious, include an
abbreviation or nearby timezone label.
Email templates are presentation-only: the controller/service should pre-format timezone-aware strings before passing them to a mailer. Do not resolve a tenant, member, or timezone inside an email template.
Convert form input on the server
A datetime-local value has no timezone. Render the stored UTC instant in the
same source timezone the controller will use when parsing the submitted value.
Template:
<?= $this->Form->control('start_date', [
'type' => 'datetime-local',
'value' => $this->Timezone->forInput(
$gathering->start_date,
null,
$currentUser,
$gathering,
),
]) ?>
Controller/service:
$timezone = TimezoneHelper::getGatheringTimezone($gathering, $identity);
if (!empty($data['start_date'])) {
$data['start_date'] = TimezoneHelper::toUtc(
$data['start_date'],
$timezone,
);
}
Resolve the timezone before patching/saving, validate the IANA identifier, and
use the same context for start and end. GatheringsController and
GatheringScheduleService are current examples.
For date-range database filters, convert local day boundaries to UTC before
building SQL comparisons. Tests in
app/tests/TestCase/Controller/DateBoundaryConversionTest.php cover this
boundary.
Optional client-side input enhancement
The global window.KMP_Timezone API and timezone-input controller can
convert data-utc-value to datetime-local on connect and replace submitted
values with hidden UTC ISO strings. This is opt-in, not required for ordinary
server-formatted inputs.
<?= $this->Form->create($gathering, [
'data-controller' => 'timezone-input',
'data-timezone-input-timezone-value' =>
TimezoneHelper::getGatheringTimezone($gathering, $currentUser),
]) ?>
<?= $this->Form->control('start_date', [
'type' => 'datetime-local',
'data-timezone-input-target' => 'datetimeInput',
'data-utc-value' => $gathering->start_date?->toIso8601String() ?? '',
]) ?>
<p class="form-text" data-timezone-input-target="notice"></p>
Choose one explicit submission contract:
- server-local contract: submit
datetime-localand call PHPtoUtc(); or - client-UTC contract: the Stimulus controller submits an ISO UTC value and the server validates/parses it as an instant.
Do not convert the same value twice. The server must validate and normalize regardless of client enhancement. See Timezone Input controller.
PHP utility reference
Important domain methods:
| Method | Use |
|---|---|
getUserTimezone() |
Member → explicit default → tenant setting → UTC |
getGatheringTimezone() |
Gathering → member → tenant setting → UTC |
getContextTimezone() |
Alias for gathering-aware resolution |
getAppTimezone() |
Tenant KMP.DefaultTimezone, defaulting to America/Chicago when seeded/read |
toUserTimezone() |
Convert a UTC value for member or Gathering display |
toUtc() |
Parse a local input in a source timezone and convert to UTC |
convertBetweenTimezones() |
Validate and convert between two named zones |
formatForDisplay() |
Member-aware conversion and formatting |
isValidTimezone() |
Validate an IANA identifier |
getTimezoneList(), getCommonTimezones() |
Select options |
getTimezoneAbbreviation(), getTimezoneOffset() |
Date-sensitive zone metadata |
clearCache() |
Clear cached app timezone and identifier list |
For view-helper overloads, inspect
app/src/View/Helper/TimezoneHelper.php or the
generated PHP API.
JavaScript utility reference
window.KMP_Timezone exposes:
detectTimezone()andgetTimezone(element);formatDateTime(),formatDate(), andformatTime();toLocalInput()andtoUTC();getTimezoneOffset()andgetAbbreviation();initializeDatetimeInputs(container); andconvertFormDatetimesToUTC(form, timezone).
Details and cautions are in KMP_Timezone utility API.
DST and validation
Always use an IANA zone, not a fixed abbreviation or numeric offset. Offsets change across daylight-saving transitions. Tests should include winter/summer dates, local midnight boundaries, and a Gathering whose timezone differs from the member and tenant defaults.
A browser datetime-local control cannot represent whether an ambiguous
fall-back hour is the first or second occurrence. Workflows where that
distinction matters need an explicit product rule and server validation. Do not
silently invent one in a controller.
Testing checklist
- Stored and queried values are UTC.
- The displayed value uses the intended member or Gathering context.
- Add/edit round trips preserve the instant through a DST boundary.
- Invalid timezone identifiers are rejected or fall back as documented.
- Local date filters use UTC boundaries.
- Email variables are pre-formatted for the intended recipient/context.
- Tenant A’s default timezone cannot affect Tenant B in long-running code.
- JavaScript-enhanced forms handle failed conversion without silently dropping the user’s value.
Run focused PHP tests for helper/controller changes, Jest for the client utility or controller, and Playwright for a complete browser form round trip.