Local time zones and locales

  1. Scenario
  2. Setup
  3. User login
  4. Usage in models
  5. LocalTime component
  6. DefaultDateTimeParser

Scenario

Following on from international dates, I also wanted times in the local timezone and format. This was a little more tricky but I think I've got a solution. This is only really appropriate if you have an international app.

Setup

Column types

Change all date, time, datetime columns to timestamp. This is because timestamp fields are converted and stored as UTC They can also accept values such as ' 2011-05-25T01:42:48+1000'. You can also then use 'set time_zone' in phpmyadmin to display timestamp fields in your local time zone.

Config

In protected/config/main.php make the following changes

// application components
'components'=>array(
	...
	'localtime'=>array(
		'class'=>'LocalTime',
		),
	...
	'db'=>array(
		...
		'initSQLs'=>array("set time_zone='+00:00';"),            
		...
	),

This makes the LocalTime class available as Yii::app()->localtime

The sql 'set time_zone' ensures that all dates retrieved are in UTC time. If your MySql server has the time zone names installed then use 'UTC' instead of '+00:00'.

Components

Copy the code at the end of this wiki for LocalTime.php and DefaultDateTimeParser.php into the folder protected/component.

User login

After the user logs in, set these 2 properties

Yii::app()->localtime->Locale = $user->locale->code; // eg 'en_gb'
			
Yii::app()->localtime->TimeZone = $user->timezone->name; // eg 'Europe/London'

I set these in the authenticate() function in protected/components/useridentity.php. The localtime component saves the values as global variables, so you only need to set them once.

For the locale and timezone data, rather than fill up this wiki, I've posted 2 mysql scripts on my blog at http://www.russellengland.com/2011/05/mysql-scripts-to-create-locale-and.html

Usage in models

For any date or time fields in your model, change the following

Rules

Use either getLocalDateTimeFormat($datewidth,$timewidth) or getLocalDateFormat($datewidth) to get the date/time format for the locale

public function rules()
{
	return array(
		...
		array('date_of_birth',
		'date',
		'format'=>
		Yii::app()->localtime->getLocalDateFormat('short')),
		...
		array('start_time',
		'date',
		'format'=>
		Yii::app()->localtime->getLocalDateTimeFormat('short','short')),
		...

Before save

Use fromLocalDateTime for both date and date+time values. This will convert the local time string into an ISO 8601 format eg: '2011-05-25T01:42:48+1000'

protected function beforeSave()
{
	if(parent::beforeSave())
	{
		...
		$this->date_of_birth = 
			Yii::app()->localtime->fromLocalDateTime(
				$this->date_of_birth,'short');
		...
		$this->start_time = 
			Yii::app()->localtime->fromLocalDateTime(
				$this->start_time,'short','short');
		...
		return true;
	}
	else
		return false;
}
After find

Finally use either toLocalDate() or toLocalDateTime() to convert the time to the local format

protected function afterFind()
{
	...
	$this->date_of_birth = 
		Yii::app()->localtime->toLocalDate(
			$this->date_of_birth,'short');
	...
	$this->start_time = 
		Yii::app()->localtime->toLocalDateTime(
			$this->start_time,'short','short');
	...		
	return (parent::afterFind());
}	
Now()

Because the sql time zone is UTC, DBExpression('NOW()') should return a UTC time. But for a belt and braces approach I use

$this->updated = Yii::app()->localtime->UTCNow;

There's also a localNow if you need to display the current time

echo Yii::app()->localtime->localNow;

LocalTime component

Copy this code to protected/component/LocalTime.php

<?php
class LocalTime extends CApplicationComponent
{
	/*
	*		Notes
	* 1.	Column field types must be timestamp not date, time, datetime
	*		This is because timestamp columns are stored as UTC then converted to the specified timezone
	*		date, time and datetime columns don't save the timezone
	* 2.	Set the timezone to UTC in protected/config/main.php
	*		so that all retrieved times are in the UTC timezone for consistency
	* 		'db'=>array(
	*		...
    *		'initSQLs'=>array("set names utf8;set time_zone='+00:00';"),
	*		),
	* 3.	When using phpMyAdmin, just use "set time_zone='+00:00'"
	*		or whatever timezone you require to display timestamps in your zone
	* 4.	Yii::app()->setTimeZone() and setLanguage() is not saved globally
	*		So this class is used to save the users timezone and locale
	* 5.	After a user logs in call Yii::app()->localtime->setLocale('en_gb');
	*		and Yii->app()->localtime->setTimeZone('Europe/London');
	* 6.	All date/time formats default to 'short' eg: dd/mm/yyyy hh:mm - no seconds
	*/

	// Used for setting/getting the global variable - change this if there are conflicts
	const _globalTimeZone = 'LocalTime_timezone';
	const _globalLocale = 'LocalTime_locale';
	// Default server time
	const _utc = 'UTC';

	// Set the timezone - usually after the user has logged in
	// Use one of the supported timezones, eg: Europe/London as this will calculate daylight saving hours
	// http://php.net/manual/en/timezones.php
	public function setTimezone($timezone)
	{
		// Save the timezone for the session
		Yii::app()->setGlobalState(self::_globalTimeZone,$timezone);
	}

	// Return the current timezone
	public function getTimezone()
	{
		// Get the localDateTimeZone if its been set
		$timezone=Yii::app()->getGlobalState(self::_globalTimeZone);

		// Default to UTC if it hasn't
		if ($timezone===null)
			$timezone=self::_utc;

		return($timezone);
	}

	// Set the locale - usually after the user has logged in
	// Use one of the supported locales, eg: en_gb
	// http://php.net/manual/en/timezones.php
	public function setLocale($locale)
	{
		// Save the timezone for the session
		Yii::app()->setGlobalState(self::_globalLocale,$locale);
	}

	// Return the current locale
	public function getLocale()
	{
		// Get the localDateTimeZone if its been set
		$locale=Yii::app()->getGlobalState(self::_globalLocale);

		// Default to yii language if it isn't - note that Yii::app()->setLanguage doesn't save globally
		if ($locale===null)
			$locale=Yii::app()->language;

		return($locale);
	}

	// Local now() function
	// Can use any of the php date() formats to return the local date/time value
	// http://php.net/manual/en/function.date.php
	public function getLocalNow($format=DATE_ISO8601)
	{
		$localnow=new DateTime(null,$this->localDateTimeZone);
		return $localnow->format($format);
	}

	// UTC Now() function
	// Can use any of the php date() formats to return the UTC date/time value
	// http://php.net/manual/en/function.date.php
	public function getUTCNow($format=DATE_ISO8601)
	{
		$utcnow=new DateTime(null,$this->serverDateTimeZone);
		return $utcnow->format($format);
	}

	// Return a datetimezone object for the local time
	public function getLocalDateTimeZone($timezone=null)
	{
		if ($timezone===null)
			$timezone = $this->timezone;

		// Create a local datetimezone object
		$datetimezone = new DateTimeZone($timezone);

		return($datetimezone);
	}

	// Return a datetimezone object for UTC
	public function getServerDateTimeZone()
	{
		// Create a local datetimezone object
		$datetimezone = new DateTimeZone(self::_utc);

		return($datetimezone);
	}

	// Converts a timestamp from UTC to a local time
	// Expects a date in Y-m-d H:i:s type format and assumes it is UTC
	// Returns a date in the local time zone
	public function fromUTC($servertimestamp)
	{
		// Its okay if an ISO8601 time is passed because the timezone in the string will be used and the _serverDateTimeZone object is ignored
		$localtime = new DateTime($servertimestamp,$this->serverDateTimeZone);

		// Then set the timezone to local and it will be automagically updated, even allowing for daylight saving
		$localtime->setTimeZone($this->localDateTimeZone);

		// Return as 2010-08-15 15:52:01 for use in the yii app
		return($localtime->format('Y-m-d H:i:s'));
	}

	// Converts a local timestamp to UTC
	// Expects a date in Y-m-d H:i:s format and assumes it is the local time zone
	// Returns an ISO date in the UTC zone
	public function toUTC($localtimestamp)
	{
		// Create an object using the local time zone - this will account for daylight saving
		$servertime = new DateTime($localtimestamp,$this->localDateTimeZone);

		// Then set the timezone to UTC and it will be automagically updated
		// In theory this step isn't needed if using the ISO8601 format.
		$servertime->setTimeZone($this->serverDateTimeZone);

		// Return as 2010-08-15T15:52:01+0000 so the timestamp column is properly updated
		return($servertime->format(DATE_ISO8601));
	}

	// Use in afterFind
	// Ensure that the SQL "set time_zone='+00:00'" has been set
	// Returns a date/time combination based on the current locale
	// Expects a date/time in the yyyy-mm-dd hh:mm:ss type format
	public function toLocalDateTime($servertimestamp,$datewidth='short',$timewidth='short')
	{
		// Create a server datetime object
		$localtime = new DateTime($servertimestamp,$this->serverDateTimeZone);

		// Then set the timezone to local and it will be automagically updated, even allowing for daylight saving
		$localtime->setTimeZone($this->localDateTimeZone);

		// Get the local yii date+time format
		$localformat = $this->getLocalDateTimeFormat($datewidth,$timewidth);

		// Convert to php date format
		$localformat = $this->YiitoPHPDateFormat($localformat);

		// Return as a local datetime
		return($localtime->format($localformat));

	}

	// Use in afterFind
	// Ensure that the SQL "set time_zone='+00:00'" has been set
	// Returns the date based on the current locale
	// Expects a date string in the yyyy-mm-dd hh:mm:ss type format
	public function toLocalDate($servertimestamp,$datewidth='short')
	{
		// Create a server datetime object
		$localtime = new DateTime($servertimestamp,$this->serverDateTimeZone);

		// Then set the timezone to local and it will be automagically updated, even allowing for daylight saving
		$localtime->setTimeZone($this->localDateTimeZone);

		// Get the local yii date only format
		$localformat = $this->getLocalDateFormat($datewidth);

		// Convert to php date format
		$localformat = $this->YiitoPHPDateFormat($localformat);

		// Return as a local datetime
		return($localtime->format($localformat));
	}

	// Use in beforeSave
	// Converts a date/time string in the locale format to an ISO time for saving to the server
	// eg 31/12/2011 will become 2011-12-31T00:00:00+0000
	public function fromLocalDateTime($localtime,$datewidth='short',$timewidth='short')
	{
		// Local datetime format
		$localformat = $this->getLocalDateTimeFormat($datewidth,$timewidth);

		// Uses a modified CDateTimeParser that defaults the time values rather than return false
		// Also returns a time string rather than a timestamp just in case the timestamp is the wrong timezone
		$defaults = array('year'=>$this->getLocalNow('Y'), 'month'=>$this->getLocalNow('m'), 'day'=>$this->getLocalNow('d'), 'hour'=>0, 'minute'=>0, 'second'=>0 );
		$timevalues = DefaultDateTimeParser::parse($localtime, $localformat, $defaults);

		// Create a new date time in the local timezone
		$servertime = new DateTime($timevalues,$this->localDateTimeZone);

		// Set the timezone to UTC
		$servertime = $servertime->setTimeZone($this->serverDateTimeZone);

		// Return it as an iso date ready for saving
		return($servertime->format(DATE_ISO8601));

	}

	// Returns the local date time format from yii
	public function getLocalDateTimeFormat($datewidth='short',$timewidth='short')
	{
		// Set the language so the local date/time format is returned
		// This assumes the locale has already been set
		Yii::app()->setLanguage($this->locale);

		// This returns the order of the date time combination, eg {1} {0}
		$localformat = Yii::app()->locale->getDateTimeFormat();

		// Get the local date format - eg dd/MM/yyyy
		$localformat = str_replace('{1}', Yii::app()->locale->getDateFormat($datewidth), $localformat);

		// Get the local time format with seconds - eg hh:mm:ss
		$localformat = str_replace('{0}', Yii::app()->locale->getTimeFormat($timewidth), $localformat);

		return $localformat;
	}

	// Just a wrapper of the yii getDateFormat function
	public function getLocalDateFormat($width='short')
	{
		// Set the language so the local date format is returned
		// Assumes the locale has been set
		Yii::app()->setLanguage($this->locale);

		// Get the local date format - eg dd/MM/yyyy
		$localformat = Yii::app()->locale->getDateFormat($width);
		return $localformat;
	}

	// Converts a yii date format to a php date format
	// Eg dd/MM/yyyy HH:mm:ss will be converted to d/m/Y H:i:s
	public function YiitoPHPDateFormat($dateformat)
	{
		$patterns = array(
			// 'ampm'=>array('a'=>'a','A'=>'A'),
			'microseconds'=>array('zzzz'=>'u','z'=>'u'),
			'seconds'=>array('ss'=>'s','s'=>'s'),
			'minutes'=>array('mm'=>'i','m'=>'i'),
			'24hours'=>array('HH'=>'H','H'=>'G'),
			'12hours'=>array('hh'=>'h','h'=>'g'),
			'years'=>array('yyyy'=>'Y','yy'=>'y'),
			'months'=>array('MM'=>'m','M'=>'n'),
			'days'=>array('dd'=>'d','d'=>'j'),
		);


		// Run through each time pattern
		foreach($patterns as $pattern)
		{
			foreach($pattern as $search=>$replace)
			{
				if (strpos($dateformat,$search)!==false)
				{
					// We have a winner!
					// Replace the first pattern found and then get out of here
					$dateformat = str_replace($search, $replace, $dateformat);
					break;
				}
			}
		}
		return($dateformat);
	}

}

DefaultDateTimeParser

Used by LocalTime, this is a copy of the CDateTimeParser but uses defaults for missing values rather then returning false. It also returns a string rather than a unix timestamp in case the time zone is wrong.

<?php

/**
 * DefaultDateTimeParser converts a date/time string to an array
 *
 * The following pattern characters are recognized:
 * <pre>
 * Pattern |      Description
 * ----------------------------------------------------
 * d       | Day of month 1 to 31, no padding
 * dd      | Day of month 01 to 31, zero leading
 * M       | Month digit 1 to 12, no padding
 * MM      | Month digit 01 to 12, zero leading
 * yy      | 2 year digit, e.g., 96, 05
 * yyyy    | 4 year digit, e.g., 2005
 * h       | Hour in 0 to 23, no padding
 * hh      | Hour in 00 to 23, zero leading
 * H       | Hour in 0 to 23, no padding
 * HH      | Hour in 00 to 23, zero leading
 * m       | Minutes in 0 to 59, no padding
 * mm      | Minutes in 00 to 59, zero leading
 * s       | Seconds in 0 to 59, no padding
 * ss      | Seconds in 00 to 59, zero leading
 * a       | AM or PM, case-insensitive (since version 1.1.5)
 * ----------------------------------------------------
 * </pre>
 *
 *
 * Modified version of http://www.yiiframework.com/doc/api/1.1/CDateTimeParser
 *
 * This version will accept a pattern and default the time values for any missing pattern
 * It returns a string rather than a timestamp in case its the wrong timezone
 * Also uses the LocalTime class to get the time for now() in the users timezone
 * For example, DefaultDateTimeParser::parse('31/12/2011','dd/MM/yyyy',array('hour'=>0,'minute'=>0,'day'=>0);
 * Will return '2011-12-2011 0:0:0'
 */
class DefaultDateTimeParser
{

	public static function parse($value,$pattern='MM/dd/yyyy',$defaults=array())
	{
		$tokens=self::tokenize($pattern);
		$i=0;
		$n=strlen($value);
		foreach($tokens as $token)
		{
			switch($token)
			{
				case 'yyyy':
				{
					if(($year=self::parseInteger($value,$i,4,4))!==null)
						$i+=4;
					break;
				}
				case 'yy':
				{
					if(($year=self::parseInteger($value,$i,1,2))!==null)
						$i+=strlen($year);
					break;
				}
				case 'MM':
				{
					if(($month=self::parseInteger($value,$i,2,2))!==null)
						$i+=2;
					break;
				}
				case 'M':
				{
					if(($month=self::parseInteger($value,$i,1,2))!==null)
						$i+=strlen($month);
					break;
				}
				case 'dd':
				{
					if(($day=self::parseInteger($value,$i,2,2))!==null)
						$i+=2;
					break;
				}
				case 'd':
				{
					if(($day=self::parseInteger($value,$i,1,2))!==null)
						$i+=strlen($day);
					break;
				}
				case 'h':
				case 'H':
				{
					if(($hour=self::parseInteger($value,$i,1,2))!==null)
						$i+=strlen($hour);
					break;
				}
				case 'hh':
				case 'HH':
				{
					if(($hour=self::parseInteger($value,$i,2,2))!==null)
						$i+=2;
					break;
				}
				case 'm':
				{
					if(($minute=self::parseInteger($value,$i,1,2))!==null)
						$i+=strlen($minute);
					break;
				}
				case 'mm':
				{
					if(($minute=self::parseInteger($value,$i,2,2))!==null)
						$i+=2;
					break;
				}
				case 's':
				{
					if(($second=self::parseInteger($value,$i,1,2))!==null)
						$i+=strlen($second);
					break;
				}
				case 'ss':
				{
					if(($second=self::parseInteger($value,$i,2,2))!==null)
						$i+=2;
					break;
				}
				case 'a':
				{
				    // If this value isn't present then ignore it
					if(($ampm=self::parseAmPm($value,$i))===null)
				        break;

				    if(isset($hour))
				    {
				    	if($hour==12 && $ampm==='am')
				    		$hour=0;
				    	else if($hour<12 && $ampm==='pm')
				    		$hour+=12;
				    }
					$i+=2;
					break;
				}
				default:
				{
					// If the separator pattern doesn't exist in the value, then ignore it
					// eg: a space
					if (strpos($value, $token)===false)
							break;
					
					$tn=strlen($token);
					if($i>=$n || substr($value,$i,$tn)!==$token)
						return false;
					$i+=$tn;
					break;
				}
			}
		}
		if($i<$n) // somethings gone wrong
			return false;

		// Defaults to the date/time for the local timezone
		// If you don't want to use Yii::app()-localtime->localNow() then simply replace with the php date() function
		// Yii::app()->localtime-> = LocalTime::
		if(!isset($year))
			$year=isset($defaults['year']) ? $defaults['year'] : Yii::app()->localtime->localNow('Y'); // date('Y');
		if(!isset($month))
			$month=isset($defaults['month']) ? $defaults['month'] : Yii::app()->localtime->localNow('n'); // date('n');
		if(!isset($day))
			$day=isset($defaults['day']) ? $defaults['day'] : Yii::app()->localtime->localNow('j'); // date('j');
		if(!isset($hour))
			$hour=isset($defaults['hour']) ? $defaults['hour'] : Yii::app()->localtime->localNow('H'); // date('H');
		if(!isset($minute))
			$minute=isset($defaults['minute']) ? $defaults['minute'] : Yii::app()->localtime->localNow('i'); // date('i');
		if(!isset($second))
			$second=isset($defaults['second']) ? $defaults['second'] : Yii::app()->localtime->localNow('s'); // date('s');

		$year=(int)$year;
		$month=(int)$month;
		$day=(int)$day;
		$hour=(int)$hour;
		$minute=(int)$minute;
		$second=(int)$second;
		

		if(CTimestamp::isValidDate($year,$month,$day) && CTimestamp::isValidTime($hour,$minute,$second))
		{
			// Return a time string rather than a timestamp because the timestamp might be the wrong timezone?
			return $year.'-'.$month.'-'.$day.' '.$hour.':'.$minute.':'.$second;
		}
		else
			return false;
	}

	/*
	 * @param string $pattern the pattern that the date string is following
	 */
	private static function tokenize($pattern)
	{
		if(!($n=strlen($pattern)))
			return array();
		$tokens=array();
		for($c0=$pattern[0],$start=0,$i=1;$i<$n;++$i)
		{
			if(($c=$pattern[$i])!==$c0)
			{
				$tokens[]=substr($pattern,$start,$i-$start);
				$c0=$c;
				$start=$i;
			}
		}
		$tokens[]=substr($pattern,$start,$n-$start);
		return $tokens;
	}

	/*
	 * @param string $value the date string to be parsed
	 * @param integer $offset starting offset
	 * @param integer $minLength minimum length
	 * @param integer $maxLength maximum length
	 */
	protected static function parseInteger($value,$offset,$minLength,$maxLength)
	{
		for($len=$maxLength;$len>=$minLength;--$len)
		{
			$v=substr($value,$offset,$len);
			if(ctype_digit($v) && strlen($v)>=$minLength)
				return $v;
		}
		// Changed by Russell England to null rather than false
		return null;
	}

	/*
	 * @param string $value the date string to be parsed
	 * @param integer $offset starting offset
	 */
	protected static function parseAmPm($value, $offset)
	{
		$v=strtolower(substr($value,$offset,2));
		return $v==='am' || $v==='pm' ? $v : false;
	}
}