Skip to content

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

@Sibyx

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)

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions