Skip to content

Fix parseDateTime() timezone handling and add datetime output options to Config #83

Description

@Sibyx

Summary

There are three distinct problems in DateTimeHelper — one parsing bug, one mutation side-effect, and one missing Config feature (regression from 1.x).

Investigation

PHP's DateTime constructor ignores the $timezone parameter when the input string already contains timezone info (Z, +00:00, etc.). For standard GPX timestamps like 2026-03-10T15:22:08Z, the 'Europe/London' default has no effect.

The setTimezone(date_default_timezone_get()) call changes the display representation but not the underlying Unix timestamp — so duration/speed computations are correct even on non-UTC servers. However, it creates surprising behavior and a real bug for edge cases.


Bug 1: parseDateTime() fallback timezone 'Europe/London'

Impact: Low for well-formed GPX, real bug for malformed GPX.

The $timezone parameter only matters when the input has no timezone info (e.g., 2026-06-15T15:22:08). During British Summer Time, Europe/London = UTC+1, so:

// During BST (e.g., June):
new DateTime("2026-06-15T15:22:08", new DateTimeZone("Europe/London"))
// → interprets as 15:22 BST = 14:22 UTC

new DateTime("2026-06-15T15:22:08", new DateTimeZone("UTC"))
// → interprets as 15:22 UTC
// 1 HOUR DIFFERENCE in timestamps — affects duration, speed, pace

Fix: Change default to 'UTC', remove setTimezone(date_default_timezone_get()):

public static function parseDateTime($value, string $timezone = 'UTC'): \DateTime
{
    $timezone = new \DateTimeZone($timezone);
    $datetime = new \DateTime($value, $timezone);
    return $datetime;
}

Bug 2: formatDateTime() mutates the original DateTime object

formatDateTime() calls $datetime->setTimezone() which permanently modifies the DateTime object in the model. This is a side-effect — calling jsonSerialize() on a Stats or Point object changes the internal timezone of startedAt/finishedAt/time.

// Current code — MUTATES the input
public static function formatDateTime($datetime, string $format = 'c', ?string $timezone = 'UTC'): ?string
{
    if ($datetime instanceof \DateTime) {
        $datetime->setTimezone(new \DateTimeZone($timezone ?? 'UTC')); // side effect!
        $formatted = $datetime->format($format);
    }
    return $formatted;
}

Fix — Option A: Clone before mutating:

public static function formatDateTime($datetime, string $format = 'Y-m-d\TH:i:sp', ?string $timezone = 'UTC'): ?string
{
    if ($datetime instanceof \DateTime) {
        $clone = clone $datetime;
        $clone->setTimezone(new \DateTimeZone($timezone ?? 'UTC'));
        return $clone->format($format);
    }
    return null;
}

Fix — Option B: Switch models to DateTimeImmutable (PHP 8.1+ available):

// Models use DateTimeImmutable — setTimezone returns new object, original unchanged
public ?\DateTimeImmutable $time = null;
public ?\DateTimeImmutable $startedAt = null;

This is the more robust approach and aligns with modern PHP practices. DateTimeImmutable::setTimezone() returns a new object — the original is never modified.


Feature: Restore datetime configuration (1.x regression)

In 1.x, users could control output format and timezone:

// 1.x — global statics
phpGPX::$DATETIME_FORMAT = 'Y-m-d H:i:s';
phpGPX::$DATETIME_TIMEZONE_OUTPUT = 'America/New_York';

// Used in Stats::toArray():
DateTimeHelper::formatDateTime($this->startedAt, phpGPX::$DATETIME_FORMAT, phpGPX::$DATETIME_TIMEZONE_OUTPUT)

In 2.x, all formatDateTime() calls use hardcoded defaults — no way to configure output timezone or format:

// 2.x Stats::jsonSerialize() — always default format + UTC
'startedAt' => DateTimeHelper::formatDateTime($this->startedAt),

Proposed: Add three datetime properties to Config:

class Config
{
    public function __construct(
        public bool $prettyPrint = true,

        /** Fallback timezone for parsing timestamps without timezone info */
        public string $dateTimeDefaultTimezone = 'UTC',

        /** Output timezone for serialization (JSON/XML) */
        public string $dateTimeOutputTimezone = 'UTC',

        /** Output format for serialization (PHP date format) */
        public string $dateTimeFormat = 'Y-m-d\TH:i:sp',
    ) {
    }
}
  • dateTimeDefaultTimezone — controls the fallback in parseDateTime() for malformed timestamps without timezone info (replaces hardcoded 'Europe/London')
  • dateTimeOutputTimezone — controls formatDateTime() output timezone (restores 1.x $DATETIME_TIMEZONE_OUTPUT)
  • dateTimeFormat — controls formatDateTime() output format (restores 1.x $DATETIME_FORMAT, default uses p specifier for Z suffix — also fixes Fix <time> XML format: use Z suffix instead of +00:00 for UTC #82)

Threading Config through serialization

Models (Stats, Point, Metadata) call formatDateTime() in their jsonSerialize() methods but don't currently have access to Config. Parsers call parseDateTime() without access to Config either. Options:

  • A. Inject Config into models/parsers — Engine sets Config on Stats when creating them; parsers receive Config from phpGPX
  • B. Keep serialization UTC-only — users who need custom formatting work with DateTime objects directly post-load

Migration from 1.x

1.x 2.x (proposed)
phpGPX::$DATETIME_FORMAT = 'c' new Config(dateTimeFormat: 'Y-m-d\TH:i:sp')
phpGPX::$DATETIME_TIMEZONE_OUTPUT = 'UTC' new Config(dateTimeOutputTimezone: 'UTC')
phpGPX::$DATETIME_TIMEZONE_OUTPUT = 'America/New_York' new Config(dateTimeOutputTimezone: 'America/New_York')
(not configurable in 1.x) new Config(dateTimeDefaultTimezone: 'UTC')

Related

Related discussion: #67 (comment)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions