Date object without timestamps (almost)

Not for 'how-to' coding questions but PHP theory instead, this forum is here for those of us who wish to learn about design aspects of programming with PHP.

Moderator: General Moderators

Post Reply
User avatar
Chris Corbyn
Breakbeat Nuttzer
Posts: 13098
Joined: Wed Mar 24, 2004 7:57 am
Location: Melbourne, Australia

Date object without timestamps (almost)

Post by Chris Corbyn »

I wasn't really sure where else to put this. It's basically something I started working on a couple of months back but haven't really got heaps of time to focus on. UNIX timestamps have the unfortunate problem of not being able to deal with dates in far out ranges (some say you can use negative timestamps, but still, you can't go very far).

I know other (PEAR) classes exist but I wanted to (roughly) recreate the Java Calendar API in PHP. The API is here:

http://java.sun.com/j2se/1.5.0/docs/api ... endar.html
http://java.sun.com/j2se/1.5.0/docs/api ... endar.html

I'm positive that without a great deal more work I can get it working but my concern is that I'm going to have to fall back to iteratively seeking through units of time when setting dates. This is mostly because I can't come up with a better logic for keeping tags of what day of the week it is, what week of the year etc etc whilst taking into account leap years etc... (if anyone can produce that logic, you're a legend!).

The esscence of the API is that you get() and set() units in the date, but you can also add() (I've called mine adjust()) to individual units of the date with negative or postive adjustments.

I was going to look at a purely literal translation of the calendar class from java but it' got too many java dependencies in it.

The reason I'm posting here is to:

a) See if anyone has better logic -- I'm sure someone will -- for the calculations
b) See if anyone wants to help finish it. It HAS to remain LGPL.

I currently fall back to using a timestamp to set-up the values in the class at instantiation, but from thereon in I don't touch the date/time functions. I'd change that too if I could.

Here's the class, sorry for lack of comments:

Code: Select all

<?php

/*
ISSUES TO ADDRESS:
 * Seeking is slow (is it???)
 * Determine day of week (seeking would be the solution)
 * Determine day of year (seeking would be the solution)
 * DST?
 * What should happen if month is changed to feb but day was 31? (the day should change to 3)
*/

//I'll add some useful debug stuff I'm sure
class SDateException extends Exception {}

/**
 * A logic-based calendar/date class for PHP.  TimeStamp independant (well, almost)
 * @license GNU Lesser General Public License
 */
class SDate
{
	const NOW = 0;
	
	const YEAR = 1;
	const MONTH = 2;
	const WEEK = 3;
	const DAY_OF_YEAR = 4;
	const DAY_OF_MONTH = 5;
	const DAY_OF_WEEK = 6;
	const HOUR = 7;
	const MINUTE = 8;
	const SECOND = 9;
	
	const SUNDAY = 0;
	const MONDAY = 1;
	const TUESDAY = 2;
	const WEDNESDAY = 3;
	const THURSDAY = 4;
	const FRIDAY = 5;
	const SATURDAY = 6;
	
	const JANUARY = 0;
	const FEBRUARY = 1;
	const MARCH = 2;
	const APRIL = 3;
	const MAY = 4;
	const JUNE = 5;
	const JULY = 6;
	const AUGUST = 7;
	const SEPTEMBER = 8;
	const OCTOBER = 9;
	const NOVEMBER = 10;
	const DECEMBER = 11;

	protected $model = array(); //Set in Ctor
	
	public function __construct($stamp=null)
	{
		if ($stamp === null) $stamp = time();
		$this->set(self::YEAR, date("Y", $stamp));
		$this->set(self::MONTH, date("n", $stamp));
		$this->set(self::WEEK, date("W", $stamp));
		$this->set(self::DAY_OF_YEAR, date("z", $stamp));
		$this->set(self::DAY_OF_MONTH, date("j", $stamp));
		$this->set(self::DAY_OF_WEEK, date("w", $stamp));
		$this->set(self::HOUR, date("G", $stamp));
		$this->set(self::MINUTE, ltrim(date("i", $stamp), "0"));
		$this->set(self::SECOND, ltrim(date("s", $stamp), "0"));
	}
	
	protected function getAffectedUnit($unit)
	{
		switch ($unit)
		{
			case self::SECOND:
				return self::MINUTE;
			case self::MINUTE:
				return self::HOUR;
			case self::HOUR:
				return self::DAY_OF_MONTH;
			case self::DAY_OF_MONTH;
				return self::MONTH;
			case self::MONTH;
				return self::YEAR;
			default:
				return false;
		}
	}
	
	public function isDefinedUnit($unit)
	{
		return ($unit >= self::YEAR && $unit <= self::SECOND);
	}
	
	public function validateRange($unit, $value)
	{
		if (!$this->isDefinedUnit($unit))
		{
			throw new SDateException(
				"Unable to validate the range given as the unit does not correspond with one of the SDate class constants");
		}
		
		$range = $this->getRange($unit);
		if ((false!== $range[0] && $value < $range[0]) || (false !== $range[1] && $value > $range[1]))
		{
			throw new SDateException("Value " . $value . " is out of range [" . implode(",", $range) . "]");
		}
	}
	
	public function getRange($unit)
	{
		if (!$this->isDefinedUnit($unit))
		{
			throw new SDateException(
				"Unable to get the allowed range as the unit does not correspond with one of the SDate class constants");
		}
		
		switch ($unit)
		{
			case self::YEAR:
				return array(-2000, 4000);
			case self::MONTH:
				return array(1, 12);
			case self::WEEK:
				return array(0, 51);
			case self::DAY_OF_YEAR:
				return array(0, $this->getDaysInYear());
			case self::DAY_OF_MONTH:
				return array(1, $this->getDaysInMonth());
			case self::DAY_OF_WEEK:
				return array(0, 6);
			case self::HOUR:
				return array(0, 23);
			case self::MINUTE:
				return array(0, 59);
			case self::SECOND:
				return array(0, 59);
		}
	}
	
	public function getDaysInMonth()
	{
		$month = $this->get(self::MONTH);
		if (in_array($month, array(1, 3, 5, 7, 8, 10, 12)))
		{
			return 31;
		}
		elseif (in_array($month, array(4, 6, 9, 11)))
		{
			return 30;
		}
		elseif ($month == 2)
		{
			if (!$this->isLeapYear()) return 28;
			else return 29;
		}
		else return 31;
	}
	
	public function getDaysInYear()
	{
		$leap_year = $this->isLeapYear();
		return $leap_year ? 365 : 364;
	}
	
	public function adjust($unit, $size)
	{
		$size = (int)$size;
		$range = $this->getRange($unit);
		$margin = $range[1] - $range[0] + 1;
		$current_value = $this->get($unit);
		if ($size < 0)
		{
			do {
				$new_value = $current_value + $size;
				$size = $new_value - $range[0];
				$current_value = $range[1] + 1;
				if (($size < 0)
					&& (false !== $affected_unit = $this->getAffectedUnit($unit)))
				{
					$this->adjust($affected_unit, -1);
				}
			} while ($new_value < $range[0]);
		}
		else
		{
			do {
				$new_value = $current_value + $size;
				$size = $new_value - $range[1];
				$current_value = $range[0] - 1;
				if (($size > 0)
					&& (false !== $affected_unit = $this->getAffectedUnit($unit)))
				{
					$this->adjust($affected_unit, 1);
				}
			} while ($new_value > $range[1]);
		}
		$this->set($unit, $new_value);
	}
	
	protected function getHcf($value, $factor)
	{
		return ($value - ($value % $factor));
	}

	public function set($unit, $value)
	{
		$unit = (int)$unit;
		$value = (int)$value;
		
		if (!$this->isDefinedUnit($unit))
		{
			throw new SDateException("You must use one of the supplied class constants to set the unit of time.");
		}
		
		$this->validateRange($unit, $value);
		
		$this->model[$unit] = $value;
		
		//Re-check some fields if needed
		if ($unit == self::MONTH && $this->has(self::DAY_OF_MONTH))
		{
			$this->validateRange(self::DAY_OF_MONTH, $this->get(self::DAY_OF_MONTH));
		}
		if ($unit == self::YEAR && $this->has(self::DAY_OF_MONTH))
		{
			$this->validateRange(self::DAY_OF_MONTH, $this->get(self::DAY_OF_MONTH));
		}
		if ($unit == self::YEAR && $this->has(self::DAY_OF_YEAR))
		{
			$this->validateRange(self::DAY_OF_YEAR, $this->get(self::DAY_OF_YEAR));
		}
	}

	public function get($unit)
	{
		$unit = (int)$unit;
		if (!$this->isDefinedUnit($unit))
		{
			throw new SDateException("You must use one of the supplied class constants to get the time.");
		}
		return $this->model[$unit];
	}
	
	protected function has($unit)
	{
		$unit = (int)$unit;
		if (!$this->isDefinedUnit($unit))
		{
			throw new SDateException("Unit not defined as class constant");
		}
		return array_key_exists($unit, $this->model);
	}

	public function isLeapYear($year=null)
	{
		if ($year === null) $year = $this->get(self::YEAR);
		
		if ($year < 1000) return false; //No leap years
		
		if ($year%4 == 0)
		{
			if ($year < 1582) return true;
			elseif ($year%100 != 0 || $year%400 == 0) return true;
		}
		
		return false;
	}
}
And here's a Unit Test:

Code: Select all

<?php

error_reporting(E_ALL);
set_time_limit(5); //In case I scew up and create an infinite loop :-\

require_once "/Users/d11wtq/simpletest/unit_tester.php";
require_once "/Users/d11wtq/simpletest/reporter.php";

require_once "./SDate.php";

class TestOfSDate extends UnitTestCase
{
	protected function assertChangesToValuesAreVisible($unit, $min, $max) {
		$date = new SDate();
		$date->set(SDate::DAY_OF_MONTH, 1);
		for ($i = 0; $i < 10; $i++)
		{
			$value = rand($min, $max);
			$date->set($unit, $value);
			$this->assertEqual($value, $date->get($unit));
		}
	}
	
	public function testChangesToYearAreVisible() {
		$this->assertChangesToValuesAreVisible(SDate::YEAR, 1700, 3000);
	}
	
	public function testChangesToMonthAreVisible() {
		$this->assertChangesToValuesAreVisible(SDate::MONTH, 1, 12);
	}
	
	public function testChangesToWeekAreVisible() {
		$this->assertChangesToValuesAreVisible(SDate::WEEK, 0, 51);
	}
	
	public function testChangesToDayOfYearAreVisible() {
		$this->assertChangesToValuesAreVisible(SDate::DAY_OF_YEAR, 1, 365);
	}
	
	public function testChangesToDayOfMonthAreVisible() {
		$this->assertChangesToValuesAreVisible(SDate::DAY_OF_MONTH, 1, 31);
	}
	
	public function testChangesToDayOfWeekAreVisible() {
		$this->assertChangesToValuesAreVisible(SDate::DAY_OF_WEEK, 0, 6);
	}
	
	public function testChangesToHourAreVisible() {
		$this->assertChangesToValuesAreVisible(SDate::HOUR, 0, 23);
	}
	
	public function testChangesToMinuteAreVisible() {
		$this->assertChangesToValuesAreVisible(SDate::MINUTE, 0, 59);
	}
	
	public function testChangesToSecondAreVisible() {
		$this->assertChangesToValuesAreVisible(SDate::SECOND, 0, 59);
	}
	
	public function testAdjustmentsToValuesAreVisible() {
		$date = new SDate();
		$date->set(SDate::YEAR, 2000);
		$date->adjust(SDate::YEAR, +3);
		$this->assertEqual(2003, $date->get(SDate::YEAR));
		$date->adjust(SDate::YEAR, -10);
		$this->assertEqual(1993, $date->get(SDate::YEAR));
		
		$date->set(SDate::MONTH, 1);
		$date->adjust(SDate::MONTH, +4);
		$this->assertEqual(5, $date->get(SDate::MONTH));
		
		$date->set(SDate::HOUR, 4);
		$date->adjust(SDate::HOUR, -4);
		$this->assertEqual(0, $date->get(SDate::HOUR));
		//assume everything works if this does since it's the same mechanism
	}
	
	public function assertOutOfRangeValuesTriggerException($unit, $min, $max, $str_unit) {
		$date = new SDate();
		$date->set(SDate::YEAR, 2001);
		try {
			$v = $min-1;
			$date->set($unit, $v);
			$this->fail("Expected value " . $v . " to be out of range for " . $str_unit);
		} catch (SDateException $e) {
			$this->pass();
		}
		
		try {
			$v = $min-20;
			$date->set($unit, $v);
			$this->fail("Expected value " . $v . " to be out of range for " . $str_unit);
		} catch (SDateException $e) {
			$this->pass();
		}
		
		try {
			$v = $max+1;
			$date->set($unit, $v);
			$this->fail("Expected value " . $v . " to be out of range for " . $str_unit);
		} catch (SDateException $e) {
			$this->pass();
		}
		
		try {
			$v = $max+20;
			$date->set($unit, $max+21);
			$this->fail("Expected value " . $v . " to be out of range for " . $str_unit);
		} catch (SDateException $e) {
			$this->pass();
		}
	}
	
	public function testSettingMonthToOutOfRangeValueThrowsException()
	{
		$this->assertOutOfRangeValuesTriggerException(SDate::MONTH, 1, 12, "MONTH");
	}
	
	public function testSettingWeekToOutOfRangeValueThrowsException() {
		$this->assertOutOfRangeValuesTriggerException(SDate::WEEK, 0, 51, "WEEK");
	}
	
	public function testSettingDayOfYearToOutOfRangeValueThrowsException() {
		$this->assertOutOfRangeValuesTriggerException(SDate::DAY_OF_YEAR, 0, 365, "DAY_OF_YEAR");
	}
	
	public function testSettingDayOfMonthToOutOfRangeValueThrowsException() {
		$this->assertOutOfRangeValuesTriggerException(SDate::DAY_OF_MONTH, 1, 31, "DAY_OF_MONTH");
	}
	
	public function testSettingDayOfWeekToOutOfRangeValueThrowsException() {
		$this->assertOutOfRangeValuesTriggerException(SDate::DAY_OF_WEEK, 0, 6, "DAY_OF_WEEK");
	}
	
	public function testSettingHourToOutOfRangeValueThrowsException() {
		$this->assertOutOfRangeValuesTriggerException(SDate::HOUR, 0, 23, "HOUR");
	}
	
	public function testSettingMinuteToOutOfRangeValueThrowsException() {
		$this->assertOutOfRangeValuesTriggerException(SDate::MINUTE, 0, 59, "MINUTE");
	}
	
	public function testSettingSecondToOutOfRangeValueThrowsException() {
		$this->assertOutOfRangeValuesTriggerException(SDate::SECOND, 0, 59, "SECOND");
	}
	
	public function testRangeForSelectedMonthsIsLessThan1To31() {
		$date = new SDate();
		$date->set(SDate::YEAR, 2001);
		$date->set(SDate::DAY_OF_MONTH, 1);
		$date->set(SDate::MONTH, 2); //Feb
		try {
			$date->set(SDate::DAY_OF_MONTH, 30);
			$this->fail("Days in Feb is 28 or 29 so 30 is out of range");
		} catch (SDateException $e) {
			$this->pass();
		}
		
		$months_with_30_days = array(4, 6, 9, 11);
		
		foreach ($months_with_30_days as $month)
		{
			$date->set(SDate::MONTH, $month);
			try {
				$this->assertNull($date->set(SDate::DAY_OF_MONTH, 30));
				$date->set(SDate::DAY_OF_MONTH, 31);
				$this->fail("Days in this month should never be more than 30");
			} catch (SDateException $e) {
				$this->pass();
			}
		}
	}
	
	public function testLeapYearIsDetectedAtCorrectYearValues() {
		$date = new SDate();
		$this->assertFalse($date->isLeapYear(1999));
		$this->assertFalse($date->isLeapYear(2003));
		$this->assertFalse($date->isLeapYear(1991));
		$this->assertTrue($date->isLeapYear(2004));
		$this->assertTrue($date->isLeapYear(2000));
		$this->assertTrue($date->isLeapYear(1996));
		//Prior to 1000AD leap years were not on the calendar
		$this->assertFalse($date->isLeapYear(994));
		$this->assertFalse($date->isLeapYear(990));
		$this->assertFalse($date->isLeapYear(400));
		//Before 1582, it was as basic as once every 4 years
		$this->assertTrue($date->isLeapYear(1004));
		$this->assertTrue($date->isLeapYear(1400));
		$this->assertTrue($date->isLeapYear(1500));
	}
	
	public function testRangeDropsToMaximumOf28DaysInFebruaryIfNotALeapYear() {
		$date = new SDate();
		$date->set(SDate::DAY_OF_MONTH, 1);
		$non_leap_years = array(2001, 1999, 1993, 1701);
		
		foreach ($non_leap_years as $year)
		{
			$date->set(SDate::YEAR, $year);
			$date->set(SDate::MONTH, 2); //Feb
			try {
				$date->set(SDate::DAY_OF_MONTH, 29);
				$this->fail("Days in Feb on non-leap year is 28 so 29 is out of range");
			} catch (SDateException $e) {
				$this->pass();
			}
		}
		
		$leap_years = array(2000, 4000, 1200, 2004);
		
		foreach ($leap_years as $year)
		{
			$date->set(SDate::YEAR, $year);
			$date->set(SDate::MONTH, 2); //Feb
			try {
				$this->assertNull($date->set(SDate::DAY_OF_MONTH, 29));
				$date->set(SDate::DAY_OF_MONTH, 30);
				$this->fail("Days in Feb on leap year is 29 so 30 is out of range");
			} catch (SDateException $e) {
				$this->pass();
			}
		}
	}
	
	public function testRangeIs366DaysPerYearOnLeapYears() {
		$date = new SDate();
		$date->set(SDate::DAY_OF_MONTH, 1);
		$non_leap_years = array(2001, 1999, 1993, 1701);
		
		foreach ($non_leap_years as $year)
		{
			$date->set(SDate::YEAR, $year);
			try {
				$date->set(SDate::DAY_OF_YEAR, 365);
				$this->fail("$year is not a leap year so cannot have 366 days");
			} catch (SDateException $e) {
				$this->pass();
			}
		}
		
		$leap_years = array(2000, 4000, 1200, 2004);
		
		foreach ($leap_years as $year)
		{
			$date->set(SDate::YEAR, $year);
			$this->assertNull($date->set(SDate::DAY_OF_YEAR, 365));
		}
	}
	
	public function testDayOfMonthIsReCheckedWhenMonthIsChanged() {
		$date = new SDate();
		$date->set(SDate::DAY_OF_MONTH, 1);
		$date->set(SDate::MONTH, 12);
		$date->set(SDate::DAY_OF_MONTH, 31);
		try {
			$date->set(SDate::MONTH, 6);
			$this->fail("June cannot have 31 days so this should have thrown exception");
		} catch (SDateException $e) {
			$this->pass();
		}
		
		$date->set(SDate::MONTH, 1);
		$date->set(SDate::DAY_OF_MONTH, 30);
		try {
			$date->set(SDate::MONTH, 2);
			$this->fail("February cannot have 30 days so this should have thrown exception");
		} catch (SDateException $e) {
			$this->pass();
		}
	}
	
	public function testDayOfMonthIsReCheckedWhenYearIsChanged() {
		$date = new SDate();
		$date->set(SDate::DAY_OF_MONTH, 1);
		$date->set(SDate::YEAR, 2000);
		$date->set(SDate::MONTH, 2);
		$date->set(SDate::DAY_OF_MONTH, 29);
		try {
			$date->set(SDate::YEAR, 1999);
			$this->fail("February cannot have 29 days in 1999 so this should have thrown exception");
		} catch (SDateException $e) {
			$this->pass();
		}
	}
	
	public function testDayOfYearIsReCheckedWhenYearIsChanged() {
		$date = new SDate();
		$date->set(SDate::YEAR, 2000);
		$date->set(SDate::DAY_OF_YEAR, 365); //Indexing starts at zero
		try {
			$date->set(SDate::YEAR, 1999);
			$this->fail("There were not 366 days in 1999 so this should have thrown exception");
		} catch (SDateException $e) {
			$this->pass();
		}
	}
	
	public function testReducingSecondBeyondZeroWrapsAround()
	{
		$date = new SDate();
		$date->set(SDate::SECOND, 10);
		$date->adjust(SDate::SECOND, -15);
		$this->assertEqual(55, $date->get(SDate::SECOND));
		$date->set(SDate::SECOND, 1);
		$date->adjust(SDate::SECOND, -2);
		$this->assertEqual(59, $date->get(SDate::SECOND));
		$date->set(SDate::SECOND, 1);
		$date->adjust(SDate::SECOND, -125);
		$this->assertEqual(56, $date->get(SDate::SECOND));
	}
	
	public function testRaisingSecondAbove59WrapsAround()
	{
		$date = new SDate();
		$date->set(SDate::SECOND, 10);
		$date->adjust(SDate::SECOND, 55);
		$this->assertEqual(5, $date->get(SDate::SECOND));
		$date->set(SDate::SECOND, 59);
		$date->adjust(SDate::SECOND, +1);
		$this->assertEqual(0, $date->get(SDate::SECOND));
		$date->set(SDate::SECOND, 51);
		$date->adjust(SDate::SECOND, 180);
		$this->assertEqual(51, $date->get(SDate::SECOND));
	}
	
	public function testReducingSecondsReducesMinutesIfNeeded()
	{
		$date = new SDate();
		$date->set(SDate::MINUTE, 59);
		$date->set(SDate::SECOND, 10);
		$date->adjust(SDate::SECOND, -15);
		$this->assertEqual(58, $date->get(SDate::MINUTE));
		
		$date->set(SDate::MINUTE, 59);
		$date->set(SDate::SECOND, 1);
		$date->adjust(SDate::SECOND, -2);
		$this->assertEqual(58, $date->get(SDate::MINUTE));
		
		$date->set(SDate::MINUTE, 59);
		$date->set(SDate::SECOND, 1);
		$minute_start = $date->get(SDate::MINUTE);
		$date->adjust(SDate::SECOND, -125);
		$this->assertEqual(56, $date->get(SDate::MINUTE));
	}
	
	public function testIncreasingSecondsIncreasesMinutesIfNeeded()
	{
		$date = new SDate();
		$date->set(SDate::MINUTE, 3);
		$date->set(SDate::SECOND, 10);
		$date->adjust(SDate::SECOND, 55);
		$this->assertEqual(4, $date->get(SDate::MINUTE));
		
		$date->set(SDate::MINUTE, 15);
		$date->set(SDate::SECOND, 58);
		$date->adjust(SDate::SECOND, 63);
		$this->assertEqual(17, $date->get(SDate::MINUTE));
	}
	
	public function testReducingMinutesBeyondZeroWrapsAround()
	{
		$date = new SDate();
		$date->set(SDate::MINUTE, 10);
		$date->adjust(SDate::MINUTE, -15);
		$this->assertEqual(55, $date->get(SDate::MINUTE));
		$date->set(SDate::MINUTE, 1);
		$date->adjust(SDate::MINUTE, -121);
		$this->assertEqual(0, $date->get(SDate::MINUTE));
		$date->set(SDate::MINUTE, 1);
		$date->adjust(SDate::MINUTE, -125);
		$this->assertEqual(56, $date->get(SDate::MINUTE));
	}
	
	public function testRaisingMinutesAbove59WrapsAround()
	{
		$date = new SDate();
		$date->set(SDate::MINUTE, 10);
		$date->adjust(SDate::MINUTE, 55);
		$this->assertEqual(5, $date->get(SDate::MINUTE));
		$date->set(SDate::MINUTE, 59);
		$date->adjust(SDate::MINUTE, +1);
		$this->assertEqual(0, $date->get(SDate::MINUTE));
		$date->set(SDate::MINUTE, 51);
		$date->adjust(SDate::MINUTE, 180);
		$this->assertEqual(51, $date->get(SDate::MINUTE));
	}
	
	public function testReducingMinutesReducesHoursIfNeeded()
	{
		$date = new SDate();
		$date->set(SDate::HOUR, 23);
		$date->set(SDate::MINUTE, 10);
		$date->adjust(SDate::MINUTE, -15);
		$this->assertEqual(22, $date->get(SDate::HOUR));
		
		$date->set(SDate::HOUR, 23);
		$date->set(SDate::MINUTE, 1);
		$date->adjust(SDate::MINUTE, -2);
		$this->assertEqual(22, $date->get(SDate::HOUR));
		
		$date->set(SDate::HOUR, 23);
		$date->set(SDate::MINUTE, 1);
		$minute_start = $date->get(SDate::HOUR);
		$date->adjust(SDate::MINUTE, -125);
		$this->assertEqual(20, $date->get(SDate::HOUR));
	}
	
	public function testIncreasingMinutesIncreasesHoursIfNeeded()
	{
		$date = new SDate();
		$date->set(SDate::HOUR, 3);
		$date->set(SDate::MINUTE, 10);
		$date->adjust(SDate::MINUTE, 55);
		$this->assertEqual(4, $date->get(SDate::HOUR));
		
		$date->set(SDate::HOUR, 15);
		$date->set(SDate::MINUTE, 58);
		$date->adjust(SDate::MINUTE, 63);
		$this->assertEqual(17, $date->get(SDate::HOUR));
	}
	
	public function testReducingHoursBeyondZeroWrapsAround()
	{
		$date = new SDate();
		$date->set(SDate::HOUR, 10);
		$date->adjust(SDate::HOUR, -15);
		$this->assertEqual(19, $date->get(SDate::HOUR));
		$date->set(SDate::HOUR, 1);
		$date->adjust(SDate::HOUR, -2);
		$this->assertEqual(23, $date->get(SDate::HOUR));
		$date->set(SDate::HOUR, 1);
		$date->adjust(SDate::HOUR, -49);
		$this->assertEqual(0, $date->get(SDate::HOUR));
	}
	
	public function testRaisingHoursAbove23WrapsAround()
	{
		$date = new SDate();
		$date->set(SDate::MINUTE, 10);
		$date->adjust(SDate::MINUTE, 55);
		$this->assertEqual(5, $date->get(SDate::MINUTE));
		$date->set(SDate::MINUTE, 59);
		$date->adjust(SDate::MINUTE, +1);
		$this->assertEqual(0, $date->get(SDate::MINUTE));
		$date->set(SDate::MINUTE, 51);
		$date->adjust(SDate::MINUTE, 180);
		$this->assertEqual(51, $date->get(SDate::MINUTE));
	}
	
	public function testReducingHoursReducesDaysIfNeeded() {
		$date = new SDate();
		$date->set(SDate::MONTH, 7);
		$date->set(SDate::DAY_OF_MONTH, 31);
		$date->set(SDate::HOUR, 1);
		
		$date->adjust(SDate::HOUR, -2);
		$this->assertEqual(30, $date->get(SDate::DAY_OF_MONTH));
		
		$date->set(SDate::DAY_OF_MONTH, 23);
		$date->set(SDate::HOUR, 11);
		$date->adjust(SDate::HOUR, -10);
		$this->assertEqual(23, $date->get(SDate::DAY_OF_MONTH));
		
		$date->set(SDate::DAY_OF_MONTH, 23);
		$date->set(SDate::HOUR, 11);
		$date->adjust(SDate::HOUR, -48);
		$this->assertEqual(21, $date->get(SDate::DAY_OF_MONTH));
	}
	
	public function testIncreasingHoursIncreasesDaysIfNeeded() {
		$date = new SDate();
		$date->set(SDate::MONTH, 7);
		$date->set(SDate::DAY_OF_MONTH, 15);
		$date->set(SDate::HOUR, 22);
		
		$date->adjust(SDate::HOUR, +3);
		$this->assertEqual(16, $date->get(SDate::DAY_OF_MONTH));
		
		$date->set(SDate::DAY_OF_MONTH, 23);
		$date->set(SDate::HOUR, 11);
		$date->adjust(SDate::HOUR, 13);
		$this->assertEqual(24, $date->get(SDate::DAY_OF_MONTH));
		
		$date->set(SDate::DAY_OF_MONTH, 23);
		$date->set(SDate::HOUR, 11);
		$date->adjust(SDate::HOUR, 48);
		$this->assertEqual(25, $date->get(SDate::DAY_OF_MONTH));
	}
	
	public function testIncreasingDaysIncreasesMonthIfNeeded() {
		$date = new SDate();
		$date->set(SDate::DAY_OF_MONTH, 1);
		$date->set(SDate::YEAR, 2001); //Not a leap year
		$date->set(SDate::MONTH, 7);
		$date->set(SDate::DAY_OF_MONTH, 30);
		
		$date->adjust(SDate::DAY_OF_MONTH, +2);
		$this->assertEqual(1, $date->get(SDate::DAY_OF_MONTH));
		$this->assertEqual(8, $date->get(SDate::MONTH));
		
		$date = new SDate();
		$date->set(SDate::DAY_OF_MONTH, 1);
		$date->set(SDate::YEAR, 2001); //Not a leap year
		$date->set(SDate::MONTH, 2);
		$date->set(SDate::DAY_OF_MONTH, 27);
		
		$date->adjust(SDate::DAY_OF_MONTH, 4);
		$this->assertEqual(3, $date->get(SDate::DAY_OF_MONTH));
		$this->assertEqual(3, $date->get(SDate::MONTH));
		
		$date = new SDate();
		$date->set(SDate::DAY_OF_MONTH, 1);
		$date->set(SDate::YEAR, 2000); //Leap year
		$date->set(SDate::MONTH, 2);
		$date->set(SDate::DAY_OF_MONTH, 27);
		
		$date->adjust(SDate::DAY_OF_MONTH, 4);
		$this->assertEqual(2, $date->get(SDate::DAY_OF_MONTH));
		$this->assertEqual(3, $date->get(SDate::MONTH));
	}
	
	public function testReducingDaysReducesMonthIfNeeded() {
	
	}
}

$test = new TestOfSDate();
$test->run(new HtmlReporter());
User avatar
feyd
Neighborhood Spidermoddy
Posts: 31559
Joined: Mon Mar 29, 2004 3:24 pm
Location: Bothell, Washington, USA

Post by feyd »

I didn't read through everything, but I did want to give you a heads-up on something. Year 4000 isn't a leap year.
User avatar
Chris Corbyn
Breakbeat Nuttzer
Posts: 13098
Joined: Wed Mar 24, 2004 7:57 am
Location: Melbourne, Australia

Post by Chris Corbyn »

feyd wrote:I didn't read through everything, but I did want to give you a heads-up on something. Year 4000 isn't a leap year.
Hmmff.. :oops:

Thank you :)
User avatar
Maugrim_The_Reaper
DevNet Master
Posts: 2704
Joined: Tue Nov 02, 2004 5:43 am
Location: Ireland

Post by Maugrim_The_Reaper »

Code: Select all

public function isLeapYear($year=null)
        {
                if ($year === null) $year = $this->get(self::YEAR);
               
                if ($year < 1000) return false; //No leap years
               
                if ($year%4 == 0)
                {
                        if ($year < 1582) return true;
                        elseif ($year%100 != 0 || $year%400 == 0) return true;
                }
               
                return false;
        }
Why the <1000 rule? The Julian calendar prior to 1582 still held all years as far back as 45BC as being a leap year if %4. So every 4th year up to 1000AD is a leaper.

Also, omit the 4000 rule. It is *not* officially recognised for several reasons. The main one (at the risk of rule breaking) is that the Gregorian Calendar's purpose is to fix Christian Easter's place in the year which unwittingly (at the time) was the right thing to do since it tracks the vernal equinox. Secondly, a leap year in 4000 (or any multiple of 4000) is meaningless since over that much time variables such as angle and rotational velocity of the planet (i.e. the planet wobbles around a bit ;)). Thirdly, both the US and UK use a different definition of the "mean year" which is incorrect (tropical not the correct vernal), and the 4000 rule (I think) was based on the correct definition which oddly enough for Catholicism agrees with the astronomical definition of the mean (the vernal equinox year). Messy, huh? I think the Russian calendar is actually more accurate by the way - we just have too many conflicting corrections for Gregorian.

Just a few other comments (that's way too big to dig through easily):

Can you perform get() operations using either the ISO or PHP date strings? e.g. $date->get('Y'); // 2007.
ADOdb has a datetime class which supposedly handles any positive timestamp range, plus dates as far back as 100AD. Might be useful assuming its readable.
User avatar
Chris Corbyn
Breakbeat Nuttzer
Posts: 13098
Joined: Wed Mar 24, 2004 7:57 am
Location: Melbourne, Australia

Post by Chris Corbyn »

Hmm... I think I either dug that leap year logic from wikipedia (:oops:) or a PEAR class. I've either cocked it up or the implementation I stole it from had it wrong. The Java version of isLeapYear() in the GregorianCalendar looks like this :?

Code: Select all

  /**
   * Determines if the given year is a leap year.  The result is
   * undefined if the gregorian change took place in 1800, so that
   * the end of february is skiped and you give that year
   * (well...).<br>
   *
   * The year should be positive and you can't give an ERA.  But
   * remember that before 4 BC there wasn't a consistent leap year
   * rule, so who cares.
   *
   * @param year a year use nonnegative value for BC.
   * @return true, if the given year is a leap year, false otherwise.  */
  public boolean isLeapYear(int year)
  {
    if ((year & 3) != 0)
      // Only years divisible by 4 can be leap years
      return false;

    // compute the linear day of the 29. February of that year.
    // The 13 is the number of days, that were omitted in the Gregorian
    // Calender until the epoch.
    int julianDay = (((year-1) * (365*4+1)) >> 2) + (31+29 -
                    (((1970-1) * (365*4+1)) / 4 + 1 - 13));

    // If that day is smaller than the gregorianChange the julian
    // rule applies:  This is a leap year since it is divisible by 4.
    if (julianDay * (24 * 60 * 60 * 1000L) < gregorianCutover)
      return true;

    return ((year % 100) != 0 || (year % 400) == 0);
  }
I can't find where gregorianCutover is defined. It's not used anywhere else in the GregorianCalendar class or the superclass which it extends :?

I hadn't planned on using any ISO8601 formats as I was just going to reproduce the Java API but it wouldn't be that tricky to map the strings to the internal class constants.
User avatar
Maugrim_The_Reaper
DevNet Master
Posts: 2704
Joined: Tue Nov 02, 2004 5:43 am
Location: Ireland

Post by Maugrim_The_Reaper »

gregorianCutover should equate to October 15, 1582AD I think. Before that date everything should be handled as Julian (i.e. leap year every 4 years) noting the 15th Oct followed the 4th (no in between days). I'm pretty sure this is defined in Java's Calendar class, or more likely a subclass for Gregorian. Have you checked the Java API Specs? Should be a java.util.Calendar listed. I'd guess it has a lot of the more fuzzy rules for transitioning between Julian and Gregorian, as well as handling of times from when Julian is accepted as being started (from 45BC to about 5 AD it was erratic).
User avatar
Chris Corbyn
Breakbeat Nuttzer
Posts: 13098
Joined: Wed Mar 24, 2004 7:57 am
Location: Melbourne, Australia

Post by Chris Corbyn »

Thanks. I appear to have taken on a project I need to do some research into before I go any further.

I have the source code of java.util.Calendar and java.util.GregorianCalendar. I also have the API here (and linked to above ;))

I instantiate new GregorianCalendar() which is a subclass of Calendar. Calendar (abstract) has no superclasses, but it does implement two interfaces (just Cloneable and Comparable).

I won't get hooked up on looking for that field though :)

I'm actually busy working on something else at the moment so probably won't even touch this code tonight.

EDIT | More than likely, the value comes from the Locale the class is using.
User avatar
Maugrim_The_Reaper
DevNet Master
Posts: 2704
Joined: Tue Nov 02, 2004 5:43 am
Location: Ireland

Post by Maugrim_The_Reaper »

Well, it should be a Date object from somewhere - so you're probably right. I can't see it as being too high level since any system date handling should be aware of the cutover.
User avatar
Chris Corbyn
Breakbeat Nuttzer
Posts: 13098
Joined: Wed Mar 24, 2004 7:57 am
Location: Melbourne, Australia

Post by Chris Corbyn »

I'm just wondering whether to rape mysql for this task and write a class which just talks to MySQL. Just a thought:

http://dev.mysql.com/doc/refman/5.0/en/ ... tions.html
User avatar
Chris Corbyn
Breakbeat Nuttzer
Posts: 13098
Joined: Wed Mar 24, 2004 7:57 am
Location: Melbourne, Australia

Post by Chris Corbyn »

Ok, I've basically written this around MySQL now (will post code very soon) but I have to ask again... 4000 isn't a leap year?? :?

According to Java's GregorianCalendar class it is :( Sadly I've just stolen the same logic now too:

See Java's implementation above...

Here's mine:

Code: Select all

/**
	 * Check if the given year is a leap year or not.
	 * This is just about 100% stolen from the Java java.util.GregorianCalendar class.
	 * Even the comments are stolen for my own sanity if I ever try to fully work out the logic!
	 * @param int The year to check
	 * @return boolean
	 */
	public function isLeapYear($year=null)
	{
		if ($year === null) $year = $this->get(self::YEAR);
		
		//Only years divisible by 4 can be leap years
		if (($year & 3) != 0) return false;
		
		//Compute linear day of the 29 February of that year.
		// The 13 is the number of days that were omitted in the
		// Gregorian Calendar before the epoch
		$julian_day = ((($year - 1) * (365 * 4 + 1)) >> 2 ) +
			(31 + 29 - (((1970 - 1) * (365 * 4 + 1)) / 4 + 1 - 13));
		
		//If that day is smaller than the Gregorian Change, the Julian rule applies:
		// This is a leap year since it is divisible by 4
		if ($julian_day * (24 * 60 * 60 * 1000) < 1582) return true;
		
		return (($year % 100) != 0 || ($year % 400) == 0);
	}
And here's what Java's GregorianCalendar says for 4000AD

Code: Select all

import java.util.GregorianCalendar;

class Whatever
{
	public static void main(String[] args)
	{
		GregorianCalendar c = new GregorianCalendar();
		if (c.isLeapYear(4000)) System.out.println("4000 is a leap year!");
		else System.out.println("4000 is NOT a leap year!");
	}
}

//Prints "4000 is a leap year!"
Hmm... It's because it's divisbile by 400 and that's explicitly written in the logic:

Code: Select all

($year % 400) == 0
User avatar
Chris Corbyn
Breakbeat Nuttzer
Posts: 13098
Joined: Wed Mar 24, 2004 7:57 am
Location: Melbourne, Australia

Post by Chris Corbyn »

Ok, after some research, it seems Java have it wrong... 4000AD is an exception to the rule I'll include in my implementation :)
User avatar
Maugrim_The_Reaper
DevNet Master
Posts: 2704
Joined: Tue Nov 02, 2004 5:43 am
Location: Ireland

Post by Maugrim_The_Reaper »

4000 is a leap year.

Of course we all must wait 1993 years or so to be absolutely certain...;). A lot of code will not recognise it as a leap year based on a proposal a long time back that it would improve accuracy. Unfortunately for an astronomer, the proposer forgot the actual time it takes Earth to complete a single solar orbit changes over time - and since it changes an adjustment out to 4000 makes no sense since you can't adjust for a constantly changing variable unless its predictable.

I wonder what the current Zend Framework classes (if they even have leap year logic) say? Not that I mind right now - I just spent part of the weekend starting a series of patches for a client because someone across the water decided it would be cool to mangle DST. Patching for this is definitely not cool - ugh.

Oh, I could shoot someone...:twisted:.
Post Reply