← 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:

  1. the member’s valid timezone;
  2. an explicitly supplied valid default;
  3. the tenant’s KMP.DefaultTimezone application setting; then
  4. UTC.

For a Gathering context, TimezoneHelper::getGatheringTimezone() resolves:

  1. the Gathering’s valid timezone;
  2. the member’s timezone;
  3. the tenant application default; then
  4. 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:

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:

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

Run focused PHP tests for helper/controller changes, Jest for the client utility or controller, and Playwright for a complete browser form round trip.