100 minut je méně než 50? Paradoxy PHP při změně času

„Kdy se sejdeme?“ – „Zítra ve tři.“ „Kdy je ta schůzka?“ – „Příští měsíc.“ Pro běžný život jsou takové údaje o čase zcela postačující. Jenže zkuste totéž v programování a rychle zjistíte, že jste vstoupili do bludiště plného nástrah a neočekávaných překvapení.

Čas v programování je jako šelma, která vypadá krotce, dokud na ni nešlápnete. A jednou z nejmocnějších lstí této šelmy je letní čas a jeho zákeřné přechody. Systém, který měl údajně ušetřit svíčky, dnes způsobuje programátorům bezesné noci (pravděpodobně kolem 2:30 ráno, kdy najednou zjistí, že jejich servery dělají podivné věci).

Vydejme se na průzkum temných zákoutí přechodů na letní čas a zpět, jak je PHP (ne)zvládá a jak jsem se pokusil napravit toto šílenství v Nette Utils. Připravte se na momenty, kdy 1 + 1 ≠ 2 a kdy přidání delšího času vám paradoxně vrátí dřívější hodinu. Tohle by nevymyslel ani Einstein.

Nejprve si prosvištíme některá slovíčka

Než se ponoříme do problematiky, vysvětleme si několik klíčových pojmů:

  • UTC (Coordinated Universal Time) – koordinovaný světový čas, základní časový standard, od kterého se odvozují všechny ostatní časové zóny. Je to v podstatě „nulový bod“ pro měření času na celém světě.
  • Časový posun (offset) – kolik hodin je potřeba přičíst nebo odečíst od UTC, abychom dostali místní čas. Označuje se jako UTC+X nebo UTC-X.
  • CET (Central European Time) – středoevropský čas, který používáme v zimě. Má posun UTC+1, což znamená, že když je v UTC poledne, u nás je 13:00.
  • CEST (Central European Summer Time) – středoevropský letní čas, který používáme v létě. Má posun UTC+2, takže když je v UTC poledne, u nás je 14:00.
  • ČEST – komunistický pozdrav, něco, co patří doufám už pouze do starý časů
  • Letní čas – systém, kdy v určité části roku (obvykle v létě) posuneme hodiny o hodinu dopředu, abychom lépe využili denní světlo.

Ten okamžik trval celý světelný rok

Pojďme si sekundu po sekundě rozebrat, jak probíhá přechod na letní čas a zpátky. Jako příklad si vezměme nedávnou změnu času v České republice v neděli 30. března 2025:

  • V 01:59:58 středoevropského času (CET) je vše normální.
  • V 01:59:59 CET je stále vše normální.
  • V další sekundě NEnastane 02:00:00 CET. Místo toho se hodiny magickým skokem posunou vpřed.
  • Následuje 03:00:00 středoevropského letního času (CEST).

Celá hodina mezi 02:00:00 a 02:59:59 v tento den lokálně „neexistuje“. Pokud jste měli mít ve 2:30 ráno důležitý telefonát, máte smůlu.

Podobně, při přechodu zpět na standardní čas (někdy označovaný jako „zimní“) na podzim (např. 26. října 2025), nastane opačná situace:

  • V 02:59:59 letního času (CEST) je vše normální.
  • V další sekundě nenastane 03:00:00 CEST. Hodiny se vrátí zpět.
  • Následuje 02:00:00 standardního středoevropského času (CET).

V tomto případě hodina mezi 02:00:00 a 02:59:59 nastane dvakrát. Poprvé v letním čase (CEST) a podruhé ve standardním čase (CET). Jak rozlišíme, kterou 2:30 myslíme? Právě pomocí označení času (CET/CEST), posunu od UTC (+01:00 / +02:00) nebo prostě slovem „letního“ / „zimního“ času.

Časové zóny: Co vlastně označuje Europe/Prague?

Když v PHP (nebo jinde) použijeme identifikátor časové zóny jako Europe/Prague, není to jen informace o aktuálním posunu od UTC. Je to odkaz na záznam v IANA Time Zone Database, která obsahuje komplexní historii a budoucí pravidla pro danou geografickou oblast:

  • Standardní posun od UTC (kolik hodin se přičítá nebo odečítá od UTC).
  • Pravidla pro letní čas (kdy začíná, kdy končí).
  • Historické změny v posunech nebo pravidlech letního času (ty se mohou měnit rozhodnutím vlád).

Existují stovky takových zón (America/New_York, Asia/Tokyo, Australia/Sydney). Některé oblasti letní čas vůbec nepoužívají (např. většina Afriky a Asie, nebo oblasti kolem rovníku) a mají po celý rok stejný posun od UTC (např. Etc/UTC nebo Africa/Nairobi).

Absolutní čas: UTC a Timestamp

Abychom se vyhnuli zmatkům s lokálními časy a letním časem, existují absolutní časové reference:

  • UTC: Jak jsme si již řekli, je to základní časový standard, který nemá letní čas. Všechny lokální časy jsou definovány jako posun od UTC. UTC je v podstatě „čistý“ čas, ke kterému si pak každá časová zóna přidá svůj posun.
  • Unix Timestamp: Počet sekund, které uplynuly od začátku Unixové éry (1. ledna 1970, 00:00:00 UTC), nepočítaje přestupné sekundy. Timestamp je také absolutní a nezávislý na časové zóně nebo letním čase.

Právě převod mezi absolutním časem (UTC/timestamp) a lokálním časem v konkrétní zóně je místo, kde vstupují do hry pravidla letního času.

PHP DateTime: Když se hodiny přetočí

Když v PHP pracujete s objektem DateTime nebo DateTimeImmutable, vždy má přiřazenou časovou zónu. Pokud ji explicitně neuvedete, použije se výchozí zóna nastavená v PHP (konfigurací nebo pomocí date_default_timezone_set()).

Co se stane, když se pokusíte vytvořit čas, který kvůli letnímu času neexistuje, nebo čas, který existuje dvakrát?

Neexistující čas (jarní skok):

// Pokus vytvořit čas v "díře" 30. března 2025
$dt = new DateTime('2025-03-30 02:30:00', new DateTimeZone('Europe/Prague'));
echo $dt->format('Y-m-d H:i:s T (P)');
// Výstup: 2025-03-30 03:30:00 CEST (+02:00)

PHP typicky „normalizuje“ tento neplatný čas tím, že ho posune vpřed o hodinu na první platný čas po skoku. Takže 02:30 se stane 03:30.

Nejednoznačný čas (podzimní návrat):

// Pokus vytvořit čas v "překryvu" 26. října 2025
$dt = new DateTime('2025-10-26 02:30:00', new DateTimeZone('Europe/Prague'));
echo $dt->format('Y-m-d H:i:s T (P)');
// Výstup: 2025-10-26 02:30:00 CET (+01:00)

PHP zde standardně zvolí druhý výskyt toho času. Proč druhý a ne první? Protože PHP považuje standardní čas (CET) za výchozí, základní stav a letní čas (CEST) pouze za dočasnou úpravu, a proto při nejednoznačnosti dává přednost standardnímu času.

Relativní časové výrazy a jejich záludnosti

Teď se dostáváme k opravdu záludné části. PHP umožňuje pracovat s relativními časovými výrazy – tedy řetězci jako +30 minutes, -1 hour nebo 1 day 2 hours. Tyto výrazy můžeme použít dvěma způsoby:

  1. Přímo v konstruktoru new DateTime('+50 minutes')
  2. V metodě $date->modify('+50 minutes')

Mimochodem, Nette tyto relativní časové výrazy odjakživa „tlačí“, protože jsou srozumitelné a přehledné. Určitě je znáte například z konfigurace „expiration“ u session nebo v dalších částech frameworku.

Intuitivně bychom čekali, že když k času přičteme delší dobu, výsledný čas bude pozdější. S relativními časovými výrazy to ale během jarního přechodu nemusí platit! A tento problém se projevuje jak při použití v konstruktoru DateTime, tak v metodě modify().

Představte si, že je právě půl druhé ráno, těsně před jarním skokem. V tu chvíli se ve vaší aplikací může odehrávat něco velmi bizarního, čeho si většina z nás ovšem nevšimne, protože buď spokojeně chrupeme v posteli, nebo ještě spokojeněji vykládáme moudra v hospodě. Jenže v serverovnách po celém světě kód tiše běží dál…

// pro všechny další příklady nastavíme výchozí časovou zónu
date_default_timezone_set('Europe/Prague');

// Je právě 2025-03-30 01:30:00 a vytvoříme DateTime s relativním časem +50 minut
$dt50 = new DateTime('+50 minutes');
echo $dt50->format('Y-m-d H:i:s T (P)');
// Výstup: 2025-03-30 03:20:00 CEST (+02:00)

„No počkat, 1:30 plus 50 minut je přece 2:20. Proč to ukazuje 3:20?“ Jak už jsme si říkali, hodina mezi 2:00 a 3:00 neexistuje. Takže 2:20 je neplatný čas, který PHP opraví tak, že ho posune o hodinu dál. Tedy na 3:20 v letním čase.

A co když k tomu stejnému výchozímu času přičteme delší interval – řekněme 100 minut?

$dt100 = new DateTime('+100 minutes');
echo $dt100->format('Y-m-d H:i:s T (P)');
// Výstup: 2025-03-30 03:10:00 CEST (+02:00)

Vidíte to! Ano, čtete správně. Po přidání 100 minut jsme dostali čas (03:10), který je dřívější než čas po přidání 50 minut (03:20).

  • +50 minutes: 01:30 + 50 min = 02:20. Tento čas neexistuje, protože je v té „ztracené hodině“. PHP ho normalizuje posunem o hodinu vpřed na 03:20 CEST.
  • +100 minutes: 01:30 + 100 min (1h 40m) = 03:10. Tento čas už existuje. PHP ho tedy použije tak, jak je.

Metoda modify() a konstruktor s relativními řetězci v PHP mají tendenci provádět aritmetiku nejprve na úrovni „hodinkového času“ a až potom řešit neplatné časy vzniklé skokem na letní čas. Výsledkem je naprosto neintuitivní chování, které většina knihoven pro práci s časem v jiných jazycích nedělá. Ty typicky interpretují +X minut jako přidání přesné doby trvání (X * 60 sekund) k absolutnímu časovému okamžiku.

DateInterval: Další vrstva komplikací

Příběh se ještě komplikuje třídou DateInterval. Ta byla vytvořena speciálně pro práci s časovými intervaly a mohla by nabízet řešení našeho problému. Jenže ouha…

K vytvoření instance DateInterval musíte použít formát podle normy ISO 8601. Upřímně, rozuměli byste na první pohled, co znamená PT100M? Ne? Já taky ne. Je to „Period of Time, 100 Minutes“ (doba trvání 100 minut). Standardizované, ale rozhodně ne na první pohled jasné.

Přesto, pokud tento podivný zápis překousneme, funguje najednou všechno správně!

$dt = new DateTime('2025-03-30 01:30:00');
$dt->add(new DateInterval('PT100M')); // 100 Minutes - ten báječný ISO 8601 formát
echo $dt->format('Y-m-d H:i:s T (P)');
// Výstup: 2025-03-30 04:10:00 CEST (+02:00) - hurá, funguje správně!

Skvělé! Tady se to opravdu chová tak, jak bychom čekali – přidá přesně 100 minut k absolutnímu času. To by mohlo být naše řešení… ale co ten podivný formát?

PHP vývojáři si byli vědomi, že PT100M není zrovna uživatelsky přívětivé, a tak přidali metodu DateInterval::createFromDateString(), která rozumí těm příjemným textovým výrazům jako 100 minutes:

$dt = new DateTime('2025-03-30 01:30:00');
$dt->add(DateInterval::createFromDateString('100 minutes'));
echo $dt->format('Y-m-d H:i:s T (P)');
// Výstup: 2025-03-30 03:10:00 CEST (+02:00) - au, zase špatně!

A jsme zase tam, kde jsme byli! Stejný problém jako s modify(). Co se to děje?

Ve skutečnosti máme co do činění s jakousi „dvojí tváří“ třídy DateInterval. Záleží na tom, jakým způsobem ji vytvoříme:

  1. Když použijeme konstruktor s ISO 8601 formátem new DateInterval('PT100M'), vytvoří se skutečná doba trvání, která se přičítá k absolutnímu času.
  2. Když použijeme createFromDateString('100 minutes'), vytvoří se spíše jakýsi kalendářní interval, který se chová podobně jako modify() – nejprve provede „hodinkovou“ aritmetiku a pak až řeší problémy s neplatnými časy.

Takže není DateInterval jako DateInterval. Je to úplně jiná tvář stejně pojmenovaného objektu podle toho, jak ho vytvoříme.

Jedna možnost řešení: Útěk do UTC

Jedním ze způsobů, jak se těmto problémům vyhnout, je provádět veškerou časovou aritmetiku v UTC, kde žádný letní čas neexistuje, a až finální výsledek převést do požadované lokální zóny:

$dt = new DateTime('2025-03-30 01:30:00');
$dt->setTimezone(new DateTimeZone('UTC')); // Převeď do UTC
$dt->modify('+100 minutes');               // Proveď operaci v UTC
$dt->setTimezone(new DateTimeZone('Europe/Prague')); // Převeď zpět
echo $dt->format('Y-m-d H:i:s T (P)');
// Správný výstup: 2025-03-30 04:10:00 CEST (+02:00)

Hurá! Nebo ne? Tento trik může být naopak kontraproduktivní, když přičítáme celé dny nebo jiné kalendářní jednotky. Nejprve si ověříme, že když přičteme 1 den k času před jarním skokem, dostaneme očekávaný výsledek:

$dt = new DateTime('2025-03-30 01:30:00'); // Před skokem (CET +01:00)
$dt->modify('+1 day');
echo $dt->format('Y-m-d H:i:s T (P)');
// Výstup: 2025-03-31 01:30:00 CEST (+02:00)

Vidíme, že při přičtení jednoho dne zůstává stejná „hodinková“ hodnota (01:30), ale mění se časová zóna z CET na CEST.

Ale co se stane, když použijeme náš UTC trik?

$dt = new DateTime('2025-03-30 01:30:00'); // Před skokem (CET +01:00)
$dt->setTimezone(new DateTimeZone('UTC')); // Převede na UTC
$dt->modify('+1 day');
$dt->setTimezone(new DateTimeZone('Europe/Prague')); // Převede zpět do lokální zóny
echo $dt->format('Y-m-d H:i:s T (P)');
// Výstup: 2025-03-31 02:30:00 CEST (+02:00) - o hodinu více!

Ups! Hodina se nám posunula z 1:30 na 2:30. Proč?

  1. Původní čas (01:30 CET) jsme převedli do UTC (00:30 UTC)
  2. Přičetli jsme den v UTC (00:30 UTC následující den)
  3. Ale následující den už platí v Praze letní čas (CEST), který má posun +2 hodiny od UTC
  4. Takže když převedeme zpět 00:30 UTC, dostaneme 02:30 CEST

Tento „útěk do UTC“ tedy může způsobit, že kalendářní operace se nebudou chovat intuitivně z pohledu lokálního času. Co je tedy vlastně správné chování? To záleží na vašich potřebách – někdy chcete zachovat absolutní časový interval (jako 24 hodin), jindy chcete zachovat kalendářní význam (jako „stejný čas následující den“).

Řešení v Nette Utils

Protože práce s časem, časovými zónami a letním časem je notoricky složitá, rozhodl jsem se do Nette Utils přidat opravu problematického chování PHP. Konkrétně do třídy Nette\Utils\DateTime, a to opravu jak konstruktoru, tak metody modify(). Jen váhám, zda nejde o BC break – k tomu se vrátím v závěru článku.

$dt = new Nette\Utils\DateTime('2025-03-30 01:30:00');
$dt->modify('+100 minutes');
echo $dt->format('Y-m-d H:i:s T (P)');
// Výstup: 2025-03-30 04:10:00 CEST (+02:00) - SPRÁVNĚ!

S Nette\Utils\DateTime je výsledek pro +100 minutes vždy pozdější než pro +50 minutes, i když je půl druhé ráno!

Kdy je 1 + 1 ≠ 2? Když pracujeme s časem!

Implementace v Nette Utils řeší i složitější případy, kdy kombinujeme přičítání dnů a hodin. Tady se dostáváme k opravdu zajímavému problému: existují totiž dva možné výklady relativního výrazu jako „+1 day +1 hour“. A tyto dvě interpretace dávají při přechodu na letní čas různé výsledky! Pojďme si to ukázat na příkladu:

První interpretace:

$dt = new \DateTime('2025-03-30 01:30:00'); // CET

$dt1 = clone $dt;
$dt1->modify('+1 day'); // Nejprve přičtu den: 2025-03-31 01:30:00 CEST
$dt1->modify('+1 hour'); // Pak přičtu hodinu: 2025-03-31 02:30:00 CEST

Druhá interpretace:

$dt2 = clone $dt;
$dt2->modify('+1 hour'); // Nejprve hodinu: 2025-03-30 03:30:00 CEST
$dt2->modify('+1 day');         // Pak den: 2025-03-31 03:30:00 CEST

Rozdíl je celá hodina! Jak vidíte, pořadí operací zde hraje zásadní roli.

V Nette\Utils\DateTime jsem zvolil první interpretaci jako výchozí chování, protože je intuitivnější. Chceme-li přičíst „1 den a 1 hodinu“, obvykle tím myslíme „stejný čas následující den plus hodina“. A co je nejlepší? Je jedno, v jakém pořadí jednotky zapíšete. Ať už použijete +1 day +1 hour nebo +1 hour +1 day, výsledek bude vždy stejný.

Tato konzistence dělá práci s časovými výrazy mnohem předvídatelnější a bezpečnější.

Čas je těžký, netrapte se sami

Práce s časem v PHP může být zrádná, zvláště kolem přechodů na letní čas. Relativní časové výrazy a dokonce i některé způsoby použití DateInterval mohou vést k neintuitivním výsledkům.

Pokud potřebujete spolehlivou manipulaci s časem:

  1. Používejte Nette\Utils\DateTime, který opravuje problematické chování.
  2. Nebo provádějte časovou aritmetiku v UTC zóně a až pak převádějte zpět do lokální zóny.
  3. Vždy testujte chování vašeho kódu během přechodů na letní čas.

Teď jen váhám, jestli oprava chování DateTime v Nette Utils nebude BC break. Upřímně si nemyslím, že by kdokoliv vědomě spoléhal na zrádné současné chování při přechodech na letní čas. Tak bych to asi zařadil do Nette Utils 4.1.

Čas je těžké téma ve všech programovacích jazycích, ne jen v PHP. Kam se na to hrabe invalidace keše.

před měsícem v rubrice PHP | blog píše David Grudl | nahoru

Mohlo by vás zajímat

Komentáře

  1. Pavel #1

    avatar

    Má to vůbec smysl řešit, když „za chvíli“ bude už jen zimní čas? 😁

    před 14 dny | odpovědět

Napište komentář

Text komentáře
Kontakt

(kvůli gravataru)



*kurzíva* **tučné** "odkaz":http://example.com /--php phpkod(); \--

phpFashion © 2004, 2025 David Grudl | o blogu

Ukázky zdrojových kódů smíte používat s uvedením autora a URL tohoto webu bez dalších omezení.