# /usr/bin/torunar

Список часовых поясов на PHP «как у взрослых»

После нескольких месяцев активной разработки “внезапно” выяснилось, что пользователи зачастую не знают, в каком часовом поясе они находятся, а потому конструкция подобного вида повергает народ в смятение:

К тому же, использование часовых поясов, заданных в формате “+ЧЧ:ММ” создавало дополнительные сложности для жителей тех регионов, где существует разделение на зимнее и летнее время или часовой пояс сменился по государственным причинам.

Чужие наработки на уровне фреймворков, жестко пробитых в массиве значений или сторонних сервисов были отметены сразу, поэтому было принято волевое решение: сделать свой, “как у взрослых”, список часовых поясов и использовать в работе не смещение относительно UTC, а аббревиатуры часовых поясов, благо, PostgreSQL умеет работать с обоими форматами:

SELECT dt AT TIME ZONE '+03:00' FROM t;
SELECT dt AT TIME ZONE 'EET'    FROM t;

Критерии “взрослости” были выбраны следующие:

  1. удобное представление
  2. полнота
  3. локализуемость

С первым критерием разобрались быстро: достаточно было посмотреть, как сделаны переключатели часовых поясов в любой ОС с GUI — да хоть в Windows.

С полнотой проблем тоже не было — в PHP уже имеется класс DateTimeZone, с помощью которого можно получить список идентификаторов часовых поясов и всю информацию о них. Обновляется он отдельно от системы и ее tzdata, поэтому до сих пор в некоторых дистрибутивах Москва, например, проходит как GMT+04:00 — проблему эту можно устранить, установив PECL'овский пакет timezonedb.

Самые большие проблемы создала локализация — проще говоря, перевод получаемого списка, который был целиком на английском, что уже было гвоздем в крышку гроба всевозможных благих начинаний.

Зарывшись в интернеты, используя гугл-фу Александра Ушакова, получилось выйти на класс IntlTimeZone из пакета php5-intl, который делал то, что нужно: превращал “Europe/Moscow” в желанное “Московское время”. С одной лишь оговоркой: перевод был крайне платформозависимым: так, на FreeBSD “Europe/Moscow” переводилось как “Москва время”, а в Linux — как “Россия (Москва)”. Учитывая такие различия, сосредоточились на production-варианте. Вторым смертным грехом было отсутствие документации на пакет Intl (Internationalization Functions), в результате на руках у нас зачаствую были лишь имена методов, иногда их описания и имена аргументов.

Первое, что выяснилось — что IntlTimeZone не очень хорошо подходит для получения аббревиатур часовых поясов. Однако, в этом себя отлично проявляет класс DateTimeZone. Поэтому, была написана первая функция, получающая аббревиатуру (“EET”) для идентификатора часового пояса (“Europe/Kaliningrad”):

<?php
/**
 * Gets timezone abbreviation from the timezone identifier.
 *
 * @param string $identifier Timezone identifier (e.g. Europe/Moscow)
 *
 * @return string Abbreviation (e.g. MSK)
 */
public static function getTimeZoneAbbreviation($identifier)
{
    $dt = new DateTime();
    $dtz = new DateTimeZone($identifier);

    return $dt->setTimezone($dtz)->format('T');
}

Получаемый в результате список был бы исчерпывающим описанием всех часовых поясов, но возникла проблема: группировка. Без нее получалась полная каша. Используя штатные возможности DateTimeZone::listIdentifiers() можно было получать список по регионам, но от этого мы отказались сразу — регион очень мало говорит о стране, к которой принадлежит тот или иной идентификатор.

Что делать? Добавить к городам и областям страны, конечно же! Но вот только IntlTimeZone ничего не знает о странах. Но зато о них знает DateTimeZone, к которому IntlTimeZone приводится. Но DateTimeZone не может предоставить локализованное имя государства — только его двухбуквенный код. Но класс Locale может предоставить нам имя государства по связанной с ним локали. Но у нас нет локали, есть только двубуквенный код. Но метод Locale::getDisplayName() умный и с криво переданными ему локалями работает хорошо — скормить ему код страны под видом локали все-таки выйдет.

Итак, у нас есть города. Есть страны. Есть коды часовых поясов. Есть информация о смещении относительно UTC. Остается все собрать.

/**
 * Gets localized list of timezones.
 *
 * @param string $locale Locale to get list for (e.g. en_US)
 *
 * @return array List of timezones
 */
public static function getList($locale = 'en_US')
{
    $tzs = [];

    $identifiers = DateTimeZone::listIdentifiers();
    foreach ($identifiers as $identifier) {
        // create date time zone from identifier
        $dtz = new DateTimeZone($identifier);

        // create timezone from identifier
        $tz = IntlTimeZone::createTimeZone($identifier);

        // get two-letter country code
        $countryCode = $dtz->getLocation()['country_code'];

        // get country name from country code
        $country = Locale::getDisplayName('_' . $countryCode, $locale);

        // replace [] with ()
        $country = str_replace(['[', ']'], ['(', ')'], $country);

        // time offset
        $offset = $dtz->getOffset(new DateTime());
        $sign = ($offset < 0) ? '-' : '+';
        $row = [
            'id' => $tz->getDisplayName(false, 3, $locale),
            'country' => $country,
            'code' => self::getTimeZoneAbbreviation($identifier),
            'offset' => $sign . date('H:i', $offset)
        ];

        // if IntlTimeZone is unaware of timezone ID, use identifier as name
        if ($tz->getID() == 'Etc/Unknown') {
            $identifier = explode('/', $identifier);
            $row['id'] = array_pop($identifier);
        }

        $tzs[] = $row;
    }

    self::sortList($tzs);

    return $tzs;
}

Весь класс целиком доступен в репозитории.

Tzs::getList() принимает на вход локаль языка, на котором нужно выводить список.

Такие вот веселые пироги получились. На исследования и написание кода ушло два или даже три дня работы, зато полученный список точно можно будет использовать в дальнейшем — и это уже совсем другая история…