// der php hacker

// archiv

Menschenlesbare Zeitangabe leicht gemacht

Geschrieben am 09. Dez 2010 von Cem Derin

Wer meinen Twitter-Account verfolgt, der wird wahrscheinlich mitbekommen haben, dass ich derzeit an einem Twitter-Service arbeite: Ein Tool, mit dem man unter anderem sehen kann, wie lange ich einem Benutzer folge (oder eben nicht mehr) bzw. wie lange mit ein anderer Nutzer folgt (oder – richtig geraten – eben nicht mehr). Natürlich könnte man einfach schreiben „seit dem 10.11.2010, 19:20“. Das ist zwar auch menschenlesbar, aber wesentlich schwerer zu erfassen als „seit einem Monat“.

Ich wollte also Zeitangaben, wie zum Beispiel Facebook sie auch nutzt, so „natürlich“ wie möglich. Mehr oder weniger im Schlaf kam mir dann die Idee, wie man das sehr leicht umsetzen kann. Im Grunde besteht das Konstrukt aus drei oder mehr Klassen. Auf die gehe ich nun ein!

Date_HumanReadable

Die Klasse, von der wir die menschenlesbare Zeitangabe bekommen. Sie hat nur eine Methode und zwei Properties. Das eine hält eine Liste der gewünschten Zeiteinheiten als Array vor, während das andere einen Standardwert für eine Toleranzschwelle enthält. Die Toleranzschwelle wird in Prozent angegeben und sorgt dafür, dass 62 Sekunden trotzdem als eine Minute erkannt werden können. In meinem Beispiel habe ich 10% verwendet. Die Liste der Einheiten ist nach Größe vorsortiert (das könnte man auch automatisieren, aber warum die Mühe). Nachfolgend der Code:

	class Date_HumanReadable {
		/**
		 * Tolerance in percent
		 * @var Int
		 */
		protected static $_tolerance = 10;

		/**
		 * Units
		 * @var array
		 */
		protected static $_units = array(
			'Date_Unit_Seconds',
			'Date_Unit_Minutes',
			'Date_Unit_Hours',
			'Date_Unit_Days',
			'Date_Unit_Weeks',
			'Date_Unit_Months',
			'Date_Unit_Years',
			/*
			*/
		);

		/**
		 * Returns a human readable representation of the difference
		 * @param Date $date
		 * @param Int $toleranceInPercent
		 */
		public static function since($date, $toleranceInPercent = null) {
			$point = strtotime($date);
			$now = time();
			$difference = $now - $point;

			if(!$tolerance) {
				$tolerance = self::$_tolerance;
			}

			foreach(self::$_units as $index => $classname) {
				$nextClassname = null;
				if(count(self::$_units) > ($index + 1)) {
					$nextClassname = self::$_units[$index+1];
				}

				/* @var $unit Date_Unit */
				$unit = new $classname($difference, $tolerance, $nextClassname);
				$unit->_differenceSeconds = $difference;
				$unit->_toleranceInPercent = $tolerance;
				$unit->_nextClass = $nextClassname;

				if($unit->isExactMatch() OR $unit->isBestGuess()) {
					$return = $unit->__toString();
					break;
				}
			}

			return $return;
		}
	}

Die Methode since errechnet also die Differenz zwischen dem jetzigen und dem übergebenen Zeitpunkt und iteriert dann über die Einheiten. Dabei wird eine Instanz der Einheit erzeugt der neben der Differenz in Sekunden auch noch der Toleranzwert sowie die mögliche nächste Einheit übergeben (sofern vorhanden). Sollte die Differenz genau einmal der Einheit entsprechen oder ist die nächst größere Einheit nicht mehr sinnvoll holen wir uns eine String Repräsentation ab und geben diese zurück. Straight forward, würde ich sagen. Aber richtig interessant ist eigentlich das, was in den Units passiert.

Date_Unit

ist eine abstrakte Klasse, die soweit die komplette Logik enthält. Die Implementationen unterscheiden sich nur noch durch die Properties. Das wären im einzelnen der Seconds-Wert. Damit wird angegeben, wieviele Sekunden dieser Zeiteinheit entsprechen. Also 1 für eine Sekunde, 60 für eine Minute, 3600 für eine Stunde und so weiter. Der nächste Wert „threshold“ ist ein Schwellwert für „geringe“ Mengen dieser Einheit im Sinne von „einigen Sekunden“, „einigen Minuten“ und so weiter. Ein sehr subjektiver Wert der auch nur bei niedrigen Einheiten verwendet werden sollte. Der nächste Wert sind die Übersetzungen. Möglich sind drei: single, some und multiple. Single für den Fall, dass wir eine Minute oder eine Stunde zurückgeben wollen. Some, wenn der angegebene Schwellwert unterschritten wurde und multiple, wenn die Einheit mehr als einmal vorkommt. Die restlichen Properties dienen nur dem zwischenspeichern.

	abstract class Date_Unit {
		/**
		 * Seconds
		 * @var Int
		 */
		public $_seconds = 0;

		/**
		 * The submitted difference in seconds
		 * @var Int
		 */
		public $_differenceSeconds = null;

		/**
		 * The submitted tolerance in percent
		 * @var Int
		 */
		public $_toleranceInPercent = null;

		/**
		 * Classname for next unit
		 * @var String
		 */
		public $_nextClass = null;

		/**
		 * Translations
		 * @var array
		 */
		public $_translations = array(
			'single'					=>	null,				// Seit einem Tag
			'some'						=>	null, 			// Seit einigen Tagen
			'multiple'				=>	null,				// Seit vier Tagen
		);

		/**
		 * Threshold to "some" in units
		 * @var Int
		 */
		public $_thresholdSome = 0;

		/**
		 * Ctor
		 * @param Int $seconds
		 * @param Int $toleranceInPercent
		 */
		public function __constructor($difference, $toleranceInPercent, $nextClass = null) {
			$this->_differenceSeconds = $difference;
			$this->_toleranceInPercent = $toleranceInPercent;
			$this->_nextClass = $nextClass;
		}

		/**
		 * Checks whether the difference matches to exact one unit (within the tolerance)
		 * @return bool
		 */
		public function isExactMatch() {
			$toleranceInSeconds = ($this->_seconds / 100) * $this->_toleranceInPercent;
			$min = $this->_seconds - $toleranceInSeconds;
			$max = $this->_seconds + $toleranceInSeconds;

			if($this->_differenceSeconds >= $min AND $this->_differenceSeconds <= $max) {
				return true;
			}

			return false;
		}

		/**
		 * Is smaller than the seconds of the next unit
		 * @return true
		 */
		public function isBestGuess() {
			if(!$this->_nextClass) return true;

			$nextClassName = $this->_nextClass;

			/* @var $nextClass Date_Unit */
			$nextClass = new $nextClassName($this->_differenceSeconds, $this->_toleranceInPercent);
			$nextClass->_differenceSeconds = $this->_differenceSeconds;
			$nextClass->_toleranceInPercent = $this->_toleranceInPercent;

			if($nextClass->getSeconds() <= $this->_differenceSeconds) return false;

			return true;
		}

		/**
		 * Returns the seconds
		 * @param bool $withTolerance
		 * @return Int
		 */
		public function getSeconds($withTolerance = true) {
			if($withTolerance) {
				$toleranceInSeconds = ($this->_seconds / 100) * $this->_toleranceInPercent;
				$min = $this->_seconds - $toleranceInSeconds;

				return $min;
			}

			return $this->_seconds;
		}

		/**
		 * Returns the string representation of the difference with this unit
		 * @return String
		 */
		public function __toString() {
			$times = round($this->_differenceSeconds / $this->_seconds);
			if($times == 1) {
				return $this->_translations['single'];
			} elseif($times <= $this->_thresholdSome) {
				return $this->_translations['some'];
			} else {
				if($times <= 4) {
					switch($times) {
						case '2':
							$times = 'zwei';
							break;

						case '3':
							$times = 'drei';
							break;

						case '4':
							$times = 'vier';
							break;
					}
				}

				return sprintf($this->_translations['multiple'], $times);
			}
		}
	}

Auch hier sind die Methoden wieder recht schnell und einfach erklärt. getSeconds liefert die Sekundenmenge dieser Einheit (auf wunsch abzüglich dem Schwellwert). isExactMatch prüft, ob die übergeben Differenz genau in den Toleranzbereich für ein Element dieser Einheit passt und isBestGuess prüft, ob eine größere Einheit existiert und ob der Schwellwert gleich oder größer der Mindestsekundenanzahl ist. Wenn ein genauer Treffer zutrifft, kein nächstgrößerer Wert vorhanden oder die Differenz zu klein für diesen ist, liefern die Methoden true zurück. Und aller Wahrscheinlichkeit nach wird die aufrufende Methode danach __toString aufrufen wollen. Hier wird errechnet, wieviele Elemente dieser Einheit in die Differenz passen, ein bisschen gerundet ggf. Zahlen durch Worte ersetzt und die entsprechende Übersetzung rausgesucht, geparst und zurückgegeben. Super easy.

Abschließend

Wenn man bedenkt, dass ich gestern Abend verwzeifelt davor saß und überlegt habe, wie ich das ganze sauber umsetzen kann (und vermutlich wegen Übermüdung auf keinen grünen Zweig kam), ist es um so lustiger, dass ich das heute spontan und in weniger als 15 Minuten ohne einen Zwischentest aus dem Ärmel schütteln konnte. Wer den Code benutzen möchte: Bitte! Die Schöpfungshöhe dürfte nicht sehr hoch sein – aber die Implementation der Einheiten müsst ihr dann schon selbst machen ;)

Ach ja: Und falls es sowas doch schon gab, auch egal. Ich hab nix gefunden. Wusste aber auch nicht so recht, wonach ich suchen soll ;)

Geschrieben in Entwicklung, PHP 4 Kommentare
#001
09. Dez 2010

[...] This post was mentioned on Twitter by Cem Derin, Benjamin Steininger. Benjamin Steininger said: RT @unset: // der php hacker: Menschenlesbare Zeitangabe leicht gemacht http://t.co/h79GUf3 via @unset [...]


#002
09. Dez 2010
Keks

Hi,

sollte das $toleranceInPercent nicht eher $tolerance heißen?


#003
10. Dez 2010
Jannik

Was ich leider immer wieder sehe:

if($nextClass->getSeconds() _differenceSeconds) return false;

return true;

Bitte mache doch einfach ein
return ($nextClass->getSeconds() > $this->_differenceSeconds);


#004
10. Dez 2010
Jannik

Man denke sich das kleiner gleich hinzu, das einfach rausgeschmissen wurde. :-(

// kommentieren

// senden
theme von mir, software von wordpress, grid von 960 grid system. funktioniert in allen browsern, aber der safari bekommt das mit der schrift am schönsten hin.