Umgang mit Zeitzonen – wie man es nicht macht!

Letzte Woche hatte ich mit einem interessanten Problem zu tun. Die Seite nutzte einen Custom-Post-Type für Webinare. Wenn man angemeldet war, dann sollte man ein paar Minuten vor dem Beginn des Webinars einen „Beitreten“ Button sehen. Es gab hierzu im Backend auch eine Einstellung für die Anzahl der Minuten, um die Anzeige des Buttons vor dem Beginn des Webinars zu steuern.

Aus irgendeinen Grund hat das aber nicht funktioniert. Da wir bereits ein anderes Problem in dem Plugin in Bezug auf Zeitberechnungen gefunden hatten, habe ich die Zeit erst einmal auf 300 Minuten vor dem Event gesetzt, was das Problem erst einmal löste, denn das Webinar war bereits seit 5 Minuten am Laufen. Aber was genau was eigentlich schiefgegangen. Bei der Suche nach dem eigentlich Fehler bin auf einen „cleveren Code“ gestoßen.

Analyse der Daten

Das Plugin verwendete Daten von einer externen API. Diese API hat mehrere Felder zurückgegeben, die dann in Postmeta gespeichert wurden. Das sind einige der verwendeten Felder:

+---------+-------------------------------+------------------------------+
| post_id | meta_key                      | meta_value                   |
+---------+-------------------------------+------------------------------+
| 1234567 | webinar_created_by            | api                          |
| 1234567 | event_id                      | 12345                        |
| ...     | ...                           | ...                          |
| 1234567 | to_date                       | Montag, 27. März 2023        |
| 1234567 | date                          | Montag, 27. März 2023        |
| 1234567 | date_en                       | 27 Mar 2023                  |
| 1234567 | to_date_en                    | 27 Mar 2023                  |
| 1234567 | start_time                    | 19:00:00                     |
| 1234567 | end_time                      | 20:45:00                     |
| 1234567 | time_zone                     | Mitteleuropäische Sommerzeit |
| ...     | ...                           | ...                          |
+---------+-------------------------------+------------------------------+

Könnt ihr etwas erkennen? Hm, vermutlich nicht, da ihr diesen Beitrag auf Deutsch lest. Aber die Daten sind teilweise übersetzt. So wird als Zeitzone der String „Mitteleuropäische Sommerzeit“ verwendet und nicht der englische Name „Central European Summer Time“ (CEST, GMT+2). Die Zeit ist also der UTC zwei Stunden voraus. Genau deshalb hatte ich auch auf die Schnelle einen Wert größer als 120 Minuten gesetzt, um sicherzugehen, dass ein solches potenzielles Problem dadurch gelöst würde. Aber wenn die Zeitzone doch eigentlich richtig angegeben ist, wieso kam es dann zu einem Fehler?

Umwandlung des Zeitzonen-Strings

In diesem Plugin gab es eine Funktion, die Zeitzonen-Strings in eine UTC-Differenz umwandelt. Die Funktion sieht in etwa wie folgt aus:

function get_timezone_mapping( $name = '' ) {
	$mapping = [
		// ...
		'Central Time' => '-6',
		'Central Standard Time' => '-6',
		'Canada Central Standard Time' => '-6',
		// ...
		'Portugal Winter Time' => '+0',
		'India Standard Time' => '+05:30',
		// ...
		'Восточноевропейское время' => '+2',
		'Eastern European Summer Time (Athens)' => '+3',
		'Eastern European Summer Time' => '+3',
		// ...
		'北京时间' => '+8',
		'台北時間' => '+8',
		'Seoul Time' => '+9',
		'日本時間' => '+9',
	];

	if ( ! empty( $name ) ) {
		if ( ! empty( $mapping[ $name ] ) ) {
			return $mapping[ $name ];
		} else {
			return false;
		}
	}

	return $mapping;
}

Man übergibt also einen String für eine Zeitzone und erhält einen String mit der Differenz zu UTC. Für einige Zeitzonen gab es mehrere Varianten und teilweise auch Übersetzungen der Bezeichnungen. Nur unsere „Mitteleuropäische Sommerzeit“ war nicht dabei, weshalb die Funktion in diesem Fall dann false zurücklieferte.

Der Zeitpunkt, an dem Dinge kaputtgingen

Nun wurde die Funktion und ihr Rückgabewert verwendet, um ein Date Objekt zu erzeugen. Es wurde dann mit der aktuellen Zeit (des Servers) verglichen. Der Code sah in etwa sie folgt auch (vereinfacht):

// Data dynamically queried from the database.
$webinar_data = [
	'time_zone' => 'Mitteleuropäische Sommerzeit',
	'date_en' => '27 Mar 2023',
	'start_time' => '19:00:00',
];
// ...
$timezone = $webinar_data['timezone'];
$start_date = $webinar_data['date_en'];
$start_time = $webinar_data['start_time'];
// ...
$timezone_mapping = get_timezone_mapping( $timezone );
$date_timezone = ! empty( $timezone_mapping ) && ! is_array( $timezone_mapping ) ? new DateTimeZone( $timezone_mapping ) : null;

$current_datetime = new DateTime( 'now', $date_timezone );
$start_datetime = $start_date ? new DateTime( $start_date . ' ' . $start_time, $date_timezone ) : null;
// ...
$pre_buffer_minutes = empty($minutes) ? 15 : absint($minutes);
// ...
// Subtract the buffer from the webinar's starting date & time.
$start_datetime->sub( new DateInterval( "PT{$pre_buffer_minutes}M" ) );
$is_within_time = $current_datetime->getTimestamp() >= $start_datetime->getTimestamp();

Lasst und das mal runterbrechen und versuchen den Fehler zu finden. Die Zeitzone des Webinars wurde an die Funktion get_timezone_mapping() übergeben, aber da es den String „Mitteleuropäische Sommerzeit“ nicht zuordnen konnte, gab sie false zurück, womit dann im Ergebnis null als Wert für $date_timezone in Zeile 13 herauskam. Diese Zeitzone wurde dann für beide Aufrufe von new Date() verwendet. Aber was passiert hier nun? Schauen wir uns dazu die beiden resultierenden Date Objekte einmal an, und welche Zeit sie repräsentieren, wenn die Aufrufe um „19:00“ Uhr (Serverzeit) passieren:

$current_datetime = (new DateTime('now', null))->format('Y-m-d H:i:s');
// 2023-03-27 17:00:00
$start_datetime = (new DateTime('27 Mar 2023 19:00:00', null))->format('Y-m-d H:i:s');
// 2023-03-27 19:00:00

Da für das zweite Date Objekt $start_datetime ein gültiger „Datumsstring“ verwendet wird, erstellt PHP ein Objekt mit exakt dieser Zeit und ignoriert dabei die Zeitzone. Das erste Date Obbjekt $current_datetime hingegen verwenden den String now und hat ebenfalls kein gültiges DateTimeZone als zweiten Parameter. Wenn ein solcher fehlt, dann verwendet PHP immer UTC als Zeitzone. Also selbst, wenn der Server (oder das WordPress-System) auf „Central European Summer Time“ läuft, wird PHP dennoch UTC verwenden. Damit erhalten wir dann die 2-Stunden-Differenz zur gewüschten Zeit. Schließlich wir dann die $is_within_time Variable false sein, bis die $pre_buffer_minutes plus der 2-Stunden-Differenz erreicht sind.

Wie geht man mit Zeitzonen besser um?

Wie ihr an dem Code sehen könnt, ist der Umgang mit Zeitzonen auf diese Weise eine schlechte Idee. Vor allem dann, wenn die Zeitzonene-Strings eventuell auch noch übersetzt sind. Man kann unmöglich eine Liste mit allen möglichen Varianten pflegen. Also was sollte man stattdessen tun?

Ich würde empfehlen einen „Industriestandard“ zu verwenden, der aich die Zeitzone verwendet, wenn man eine Uhrzeit und ein Datum definiert. In der Tabelle am Beginn der Blogbeitrags siehr man selbst für das Datum zwei Schreibweisen: „Montag, 27. März 2023“ und „27 Mar 2023“. Der erste ist dabei auch noch übersetzt. Durch das Aufteilen der Werte für Zeit, Datum und Zeitzonen auf meherer Felder muss man diese dann wieder zusammensetzen (wie in Zeile 16), in daraus dann ein „Datumsobjekt“ in verschiedenen Programmiersprachen erstellen zu können. Aber wieso verwenden wir nicht ein Format wie das folgende, das alle Teile bereits kombiniert?

$webinar_start = 'Mon, 27 Mar 2023 19:00:00 +0200';

Dieser Strings enthält alle Teile für Zeit, Datum und Zeitzone. Das ist auch der Wert den man erhält, wenn man das $date->format('r') Ausgabeformat wählt. Es folgt den Standards RFC 2822/RFC 5322. Wenn jemand nun eure API verwendet, können sie dann aus diesem String ein „Datumsobjekt“ erstellen und davon dann Zeit, Datum, oder andere Werte auslesen.

Fazit

Der Umgang mit Zeitzonen ist bei der Programmierung oft nicht einfach. Wenn man dann auch noch mit verschiednen Zeitzonen zu tun hat, wird es nochmals schwerer. Ihr könnt euch naütrlich dennoch dafür entscheiden, die einzelnen Werte für Zeit/Datum auch separat anzubieten, aber bitte bieete auch einen String wie oben beschrieben an. Und solltet ihr ech entscheiden dies nicht zu tun, dann übersetzt bitte die Namen von Zeitzonen, Monatsnamen oder ähnlichem nicht auch noch, da es sonst zu dem erwähnten (und manchmal schwer zu lösenden) Problem führen kann.

Für dieses spezielle Plugin habe ich bisher noch keine Lösung gefunden. Wenn die API einen solchen String in Zukunft nicht anbietet, dann wird mir wohl nichts anderes übrig bleiben als das statische Array in der Funktion immer wieder mit weiteren (übersetzten) Zeitzonen.String zu erweitern. Ich werde mit aber nie sicher sein können, dass nicht eines Tages ein neuer Wert dazu kommt, den ich noch nicht habe, und der dann wieder zu diesem Problem führt.

Veröffentlicht von

Bernhard ist fest angestellter Webentwickler, entwickelt in seiner Freizeit Plugins, schreibt in seinem Blog über WordPress und andere Themen, treibt sich gerne bei den WP Meetups in Berlin und Potsdam herum und läuft nach Feierabend den ein oder anderen Halbmarathon.

2 Kommentare » Schreibe einen Kommentar

  1. Spannend, dass sie so viele Daten in der API zurückgeben, aber einen einfachen Offset à la +02:00 nicht. Damit wären die Probleme ja praktisch auch gelöst. ?

    • Ja, genau das wollte ich ja damit ausdrücken ?Auf der Seite wird der Wert von time_zone zwar auch als String ausgegeben, aber es fehlt eben der „technische Datumsstring“, der eine Verarbeitung im Code einfacher machen würde.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert