Hallo nochmal,
Ich habe mir nochmal überlegt, ob man die Variante für PHP < 5.2 nicht auch verständlicher implementieren könnte. Vielleicht erklärt das auch viel besser, welche Herangehensweise bei Datumsberechnungen sinnvoll ist.
Dabei bin ich auf folgende Lösung gekommen:
// Wochen-Start und Ende einer ISO8601-Woche in einem gegebenen
// Jahr bestimmen (als UNIX-Timestamp)
function wocheInfo ($jahr, $woche) {
// Stelle fest, ob das Jahr ein Schaltjahr ist
$schaltjahr = (int)($jahr % 4 == 0 && (!($jahr % 100 == 0) || $jahr % 400 == 0));
// Wochentag des 4. Januars berechnen
$wochentag = (date ('w', mktime (0, 0, 0, 1, 4, $jahr)) + 6) % 7;
// Start des Jahres relativ zum 1. bestimmen
$start = 4 - $wochentag;
// Addiere $woche * 7
$start += ($woche - 1) * 7;
if ($start <= 0) {
// Start-Datum liegt im Dezember des Vorjahres
$startTs = mktime (0, 0, 0, 12, 31 + $start, $jahr - 1);
} else if ($start <= 59 + $schaltjahr) {
// Januar oder Februar
$startTs = mktime (0, 0, 0, floor ($start / 31) + 1, $start % 31, $jahr);
} else {
// März oder später
$tag = $start + 63 - $schaltjahr;
$monat = floor ($tag / 30.6001);
$tag -= floor ($monat * 30.6001);
$startTs = mktime (0, 0, 0, $monat - 1, $tag, $jahr);
}
// Addiere 6 Tage
$ende = $start + 6;
if ($ende <= 59 + $schaltjahr) {
// Januar oder Februar
$endeTs = mktime (23, 59, 59, floor ($ende / 31) + 1, $ende % 31, $jahr);
} else {
// März oder später (Januar nächsten Jahres wird automatisch
// von mktime() interpoliert)
$tag = $ende + 63 - $schaltjahr;
$monat = floor ($tag / 30.6001);
$tag -= floor ($monat * 30.6001);
$endeTs = mktime (23, 59, 59, $monat - 1, $tag, $jahr);
}
return array ($startTs, $endeTs);
}
Das Grundprinzip ist das gleiche, wie bei der vorigen Lösung: Es wird eine kontinuierliche Zeitskala verwendet, der Start des ISO-Jahres in dieser Zeitskala berechnet, die Anzahl an Wochen auf diese Skala addiert, auf die Monate zurückgerechnet und schließlich Timestamps wieder per mktime() gebildet.
Der wichtige Unterschied: Als kontinuierliche Zeitskala wird der Tag im Jahr genommen, d.h. der 1. Januar wäre "1", der 31. Dezember wäre "365" oder "366" (je nachdem ob's ein Schaltjahr ist, oder nicht). Damit ist die Jahreszahl für die Zeitskala vollkommen irrelevant (außer für die Info, ob's ein Schaltjahr ist, oder nicht) und der folgende Code verdeutlicht, wie die Berechnung von Monaten funktioniert. Gehen wir den Code Schritt für Schritt durch:
$schaltjahr = (int)($jahr % 4 == 0 && (!($jahr % 100 == 0) || $jahr % 400 == 0));
Dieser Code überprüft, ob das gegebene Jahr ein Schaltjahr ist, d.h. einen 29. Februar besitzt. Dies ist der Fall, wenn das Jahr durch 4 teilbar ist (d.h. die Modulo-Operation mit 4 den Rest 0 ergibt), aber nicht durch 100 teilbar ist oder doch wieder durch 400 teilbar ist (Schaltjahresregel des gregorianischen Kalenders). Der Ausdruck in den äußeren Klammern ist dann ein Bool-Wert, der true ist, falls das Jahr ein Schaltjahr ist und false, falls nicht. Der Witz ist nun, dass in PHP die Konvertierung nach (int) true zu 1 macht und false zu 0, d.h. in der Variable $schaltjahr steht jetzt nun genau die Anzahl an zusätzlichen Tagen im Februar: 1 in Schaltjahren und 0 in normalen Jahren.
$wochentag = (date ('w', mktime (0, 0, 0, 1, 4, $jahr)) + 6) % 7;
Dieser Code berechnet nun den Wochentag des 4. Januars des Jahres. Das ist der einzige Teil des Codes, an dem "geschummelt" wird, d.h. die eigentliche Kalenderberechnung PHP selbst überlassen wird. Dies vereinfacht den Code jedoch dramatisch. PHPs date('w') gibt jedoch 0 für Sonntag und 6 für Samstag zurück, für die weitere Berechnung ist aber 0 für Montag und 6 für Sonntag sinnvoller. Daher wird auf das Ergebnis 6 addiert und das ganze noch einmal Modulo 7 genommen, dann passt's.
$start = 4 - $wochentag;
Dies berechnet das Startdatum in unserer kontinuierlichen Zeitskala. Da die Zeitskala der Tag im Jahr ist, wäre »4« der 4. Januar. Hiervon ziehen wir noch einmal den Wochentag ab, um den Start des ISO-Jahres zu erhalten (der Start des normalen Jahres wäre ganz simpel »1«). Zur Erinnerung: Das ISO-Wochenjahr fängt mit dem Montag der ersten Woche an, die mindestens 4 Tage im normalen Jahr besitzt. Wenn der 4. Januar ein Montag ist, fängt es also am 4. Januar an (weil der 1., 2. und 3. Januar noch zur vorigen Woche gehören, die jedoch nur 3 Tage in dem Jahr hat). In dem Fall ist $wochentag 0 und wir ziehen 0 ab. Wenn der 4. Januar ein Dienstag ist, hat das Jahr am 3. Januar angefangen, dann ist $wochentag 1 (für Dienstag), wir ziehen 1 ab, dann stimmt auch das. Usw. usf.
$start += ($woche - 1) * 7;
Nun wird der Start der gewünschten Woche berechnet. Da wir bereits in Woche 1 sind, müssen wir von $woche 1 abziehen vor der Multiplikation, damit das Ergebnis stimmt. $start enthält den Tag im Jahr des Montags der Woche Nr $woche. Nun müssen Monat und Tag dazu berechnet werden, um den Start-Timestamp zu erhalten.
if ($start <= 0) {
Wenn $start <= 0 ist, dann war die erste Woche gemeint und das Startdatum liegt im vorigen Jahr (weil ja, wenn z.B. der 4. Januar ein Sonntag ist, 4 Tage der Woche, die am 29. Dezember des Vorjahres begann, bereits im neuen Jahr liegen und somit der ISO-Jahresanfang am 29. Dezember des Vorjahres liegt).
$startTs = mktime (0, 0, 0, 12, 31 + $start, $jahr - 1);
Monat ist also Dezember, der hat immer 31 Tage, daher muss einfach 31 auf $start (das 0, -1 oder -2 sein kann) addiert werden und $jahr - 1 muss genommen werden.
} else if ($start <= 59 + $schaltjahr) {
Wenn $start <= 59 (oder in Schaltjahren 60) ist, dann sind wir in Januar oder Februar...
$startTs = mktime (0, 0, 0, floor ($start / 31) + 1, $start % 31, $jahr);
... d.h. durch einfache Division / Modulo-Rechnung mit 31 erhalten wir sowohl Monat als auch Tag.
} else {
Wenn wir weder im Dezember letzten Jahres noch im Januar noch im Februar sind, müssen wir im März des darauffolgenden Jahres sein.
$tag = $start + 63 - $schaltjahr;
Hier wird Tag initialisiert, so dass man gut den Monat und Tag daraus berechnen kann. Das Grundprinzip ist folgendes: Berechnet man für alle Monate von März bis Februar des darauffolgenden Jahres floor (($monat + 1) * 30.6001), so erhält man folgende Zahlenfolge:
Monat | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
-------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----
Formel | 122 | 153 | 183 | 214 | 244 | 275 | 306 | 336 | 367 | 397 | 428 | 459
Daran erkennt man noch nicht allzu viel. Zieht man jedoch 63 davon ab, d.h. berechnet man floor (($monat + 1) * 30.6001) - 63, so erhält man die nächste Tabelle:
Monat | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
-------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----
Formel | 59 | 90 | 120 | 151 | 181 | 212 | 243 | 273 | 304 | 334 | 365 | 396
Bedenkt man nun, dass in einem normalen Jahr der 1. März der 60. Tag im Jahr ist, der 1. April der 91., der 1. Mai der 121 Tag etc., so erkennt man denke ich das Muster. Sprich: Die Formel floor (($monat + 1) * 30.6001) - 63 + $tag + $schaltjahr liefert für alle Daten ab März den korrekten Tag im Jahr.
Um das ganze zurückzurechnen, addiert man eben 63 - $schaltjahr auf den Tag im Jahr und kann dann einfach durch Division und Modulo-Rechnung (die man auf Grund der Tatsache, dass 30.6001 keine ganze Zahl ist, nachbilden muss) den Monat und Tag ausrechnen, was im folgenden auch getan wird:
$monat = floor ($tag / 30.6001);
Hier wird zuerst der Monat berechnet (genauer gesagt ist $monat nun der Monat + 1, d.h. 4 für März etc.), indem die Anzahl an Tagen durch 30.6001 dividiert wird und dann abgerundet wird.
$tag -= floor ($monat * 30.6001);
Hier wird nun der Rest zum Monatsanfang berechnet, was dann der Tag im Monat ist.
$startTs = mktime (0, 0, 0, $monat - 1, $tag, $jahr);
Von $monat muss man 1 abziehen, damit die Angabe stimmt (s.o.), $tag stimmt dagegen, da die Formel immer die Tage VOR Monatsbeginn liefert. Ein kleiner Hinweis - dies kann beim Enddatum passieren, beim Anfangsdatum nicht: Sollte der ursprüngliche Tag bereits im nächsten Jahr liegen (d.h. > 365/366 sein), dann wäre hier $monat - 1 natürlich 13 und $tag würde dennoch stimmen. Das macht aber nichts, da mktime() in der Berechnung von solchen Offsets stabil ist, d.h. ein 13. Monat wird Problemlos als 1. Monat des nächsten Jahres akzeptiert.
$ende = $start + 6;
Nun muss das Enddatum berechnet werden, was analog zum Anfangsdatum geht, nur 6 Tage später liegt (und zu einer anderen Uhrzeit). Da der Code identisch ist, gehe ich nicht näher darauf ein, lediglich die Bedingung if ($ende <= 0) wird weggelassen, da das Ende einer Woche niemals im Vorjahr liegen kann.
return array ($startTs, $endeTs);
Das Ergebnis muss natürlich auch zurückgegeben werden. ;-)
Ich hoffe, ich konnte hiermit eine Berechnungsmethode für PHP < 5.2 vorstellen, die verständlicher als die vorige ist, aber dennoch einen Einblick in die Tricks der Datums- und Zeitberechnung liefert.
Viele Grüße,
Christian