Enhances event handling with timezone support

Adds timezone handling to the event parsing and formatting logic.

This allows events to be displayed in the user's local timezone, improving the user experience.

Also restructures the event display on the "Termine" page to include a sidebar for filtering by year and month.
This commit is contained in:
2025-09-06 12:27:56 +02:00
parent 94078a7e6f
commit 15de43a2b5
3 changed files with 339 additions and 174 deletions
+115 -39
View File
@@ -1,66 +1,142 @@
<?php
<?php
// URL der öffentlichen ICS-Datei
define(
'CAL_URL',
'https://calendar.google.com/calendar/ical/jv1bq94un3ivoa8ka0rk9ngq4k%40group.calendar.google.com/public/basic.ics',
'CAL_URL',
'https://calendar.google.com/calendar/ical/jv1bq94un3ivoa8ka0rk9ngq4k%40group.calendar.google.com/public/basic.ics',
);
function format_ics_date($date)
{
// Unterstützt sowohl ganztägige als auch Zeitangaben
if (strpos($date, 'T') !== false) {
// Unterstützt sowohl ganztägige als auch Zeitangaben
if (strpos($date, 'T') !== false) {
$dt = DateTime::createFromFormat('Ymd\THis', substr($date, 0, 15));
return $dt ? $dt->format('d.m.Y H:i') : $date;
} else {
$dt = DateTime::createFromFormat('Ymd', $date);
return $dt ? $dt->format('d.m.Y') : $date;
}
}
function format_ics_date_with_timezone($date, $timezone = null)
{
// Unterstützt sowohl ganztägige als auch Zeitangaben
if (strpos($date, 'T') !== false) {
$dt = null;
// Prüfe ob es eine UTC-Zeit ist (endet mit Z)
if (substr($date, -1) === 'Z') {
// UTC-Zeit: 20250405T130000Z
$utc_date = substr($date, 0, -1); // Entferne das Z
$dt = DateTime::createFromFormat('Ymd\THis', $utc_date, new DateTimeZone('UTC'));
} elseif ($timezone) {
// Zeitzone angegeben: 20250405T130000 mit TZID
try {
$dt = new DateTime($date, new DateTimeZone($timezone));
} catch (Exception $e) {
// Fallback auf lokale Zeit
$dt = DateTime::createFromFormat('Ymd\THis', substr($date, 0, 15));
return $dt ? $dt->format('d.m.Y H:i') : $date;
}
} else {
$dt = DateTime::createFromFormat('Ymd', $date);
return $dt ? $dt->format('d.m.Y') : $date;
// Lokale Zeit ohne Zeitzone
$dt = DateTime::createFromFormat('Ymd\THis', substr($date, 0, 15));
}
if ($dt) {
// ISO 8601 Format für JavaScript
$iso_date = $dt->format('Y-m-d\TH:i:sP'); // P = Zeitzone
$display_date = $dt->format('d.m.Y H:i');
return [
'iso' => $iso_date,
'display' => $display_date,
'has_time' => true,
];
}
return [
'iso' => $date,
'display' => $date,
'has_time' => true,
];
} else {
$dt = DateTime::createFromFormat('Ymd', $date);
if ($dt) {
$iso_date = $dt->format('Y-m-d');
$display_date = $dt->format('d.m.Y');
return [
'iso' => $iso_date,
'display' => $display_date,
'has_time' => false,
];
}
return [
'iso' => $date,
'display' => $date,
'has_time' => false,
];
}
}
// ICS-Datei laden
$ics = @file_get_contents(CAL_URL);
if (! $ics) {
echo '<div class="text-red-600">Kalender konnte nicht geladen werden.</div>';
if (!$ics) {
echo '<div class="text-red-600">Kalender konnte nicht geladen werden.</div>';
return;
return;
}
// Termine parsen
function parse_ics($ics)
{
$lines = explode("\n", $ics);
$events = [];
$event = [];
$inEvent = false;
foreach ($lines as $line) {
$line = trim($line);
if ($line === 'BEGIN:VEVENT') {
$inEvent = true;
$event = [];
} elseif ($line === 'END:VEVENT') {
$inEvent = false;
$events[] = $event;
} elseif ($inEvent) {
// Property kann Parameter enthalten, z.B. DTSTART;TZID=Europe/Berlin:20240701T19000000
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$key = $parts[0];
$val = $parts[1];
// Nur den eigentlichen Property-Namen extrahieren
$key = strtoupper(preg_replace('/;.+$/', '', $key));
$event[$key] = $val;
}
}
}
$lines = explode("\n", $ics);
$events = [];
$event = [];
$inEvent = false;
foreach ($lines as $line) {
$line = trim($line);
if ($line === 'BEGIN:VEVENT') {
$inEvent = true;
$event = [];
} elseif ($line === 'END:VEVENT') {
$inEvent = false;
$events[] = $event;
} elseif ($inEvent) {
// Property kann Parameter enthalten, z.B. DTSTART;TZID=Europe/Berlin:20240701T19000000
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$key = $parts[0];
$val = $parts[1];
return $events;
// Zeitzoneninformation extrahieren
$timezone = null;
if (preg_match('/TZID=([^:;]+)/', $key, $matches)) {
$timezone = $matches[1];
}
// Nur den eigentlichen Property-Namen extrahieren
$cleanKey = strtoupper(preg_replace('/;.+$/', '', $key));
$event[$cleanKey] = $val;
// Zeitzoneninformation separat speichern
if ($timezone && in_array($cleanKey, ['DTSTART', 'DTEND'])) {
$event[$cleanKey . '_TZID'] = $timezone;
}
}
}
}
return $events;
}
$events = parse_ics($ics);
return function () use ($events) {
return $events;
return $events;
};
+90 -38
View File
@@ -1,34 +1,34 @@
<?php
$events = collection('termine');
$events = collection('termine');
// Aktuelles Datum
$today = (new DateTime())->format('Ymd');
// Aktuelles Datum
$today = new DateTime()->format('Ymd');
// Nur zukünftige Termine anzeigen
$future_events = array_filter($events, function ($event) use ($today) {
return isset($event['DTSTART']) && $event['DTSTART'] >= $today;
});
// Nur zukünftige Termine anzeigen
$future_events = array_filter($events, function ($event) use ($today) {
return isset($event['DTSTART']) && $event['DTSTART'] >= $today;
});
// Termine nach DTSTART in aufsteigender Reihenfolge sortieren
usort($future_events, function ($a, $b) {
return $a['DTSTART'] <=> $b['DTSTART'];
});
// Termine nach DTSTART in aufsteigender Reihenfolge sortieren
usort($future_events, function ($a, $b) {
return $a['DTSTART'] <=> $b['DTSTART'];
});
// --- Deutsche Monatsnamen ---
$de_months = [
'01' => 'Jan',
'02' => 'Feb',
'03' => 'Mrz',
'04' => 'Apr',
'05' => 'Mai',
'06' => 'Jun',
'07' => 'Jul',
'08' => 'Aug',
'09' => 'Sep',
'10' => 'Okt',
'11' => 'Nov',
'12' => 'Dez',
];
// --- Deutsche Monatsnamen ---
$de_months = [
'01' => 'Jan',
'02' => 'Feb',
'03' => 'Mrz',
'04' => 'Apr',
'05' => 'Mai',
'06' => 'Jun',
'07' => 'Jul',
'08' => 'Aug',
'09' => 'Sep',
'10' => 'Okt',
'11' => 'Nov',
'12' => 'Dez',
];
?>
<div class="container mx-auto py-8">
@@ -43,16 +43,19 @@
<div id="termine-container" class="flex space-x-6 pb-4 transition-transform duration-300 ease-in-out">
<?php foreach ($future_events as $event): ?>
<?php
$start = $event['DTSTART'] ?? '';
$end = $event['DTEND'] ?? '';
$summary = $event['SUMMARY'] ?? '';
$start = $event['DTSTART'] ?? '';
$end = $event['DTEND'] ?? '';
$summary = $event['SUMMARY'] ?? '';
$location = $event['LOCATION'] ?? '';
$desc = $event['DESCRIPTION'] ?? '';
$date = format_ics_date($start);
$time = (str_contains($start, 'T')) ? substr($date, 11) : 'ganztägig';
$day = substr($date, 0, 2);
$month = substr($date, 3, 2);
?>
$desc = $event['DESCRIPTION'] ?? '';
$timezone = $event['DTSTART_TZID'] ?? null;
$date_info = format_ics_date_with_timezone($start, $timezone);
$date = $date_info['display'];
$time = $date_info['has_time'] ? substr($date, 11) : 'ganztägig';
$day = substr($date, 0, 2);
$month = substr($date, 3, 2);
$iso_date = $date_info['iso'];
?>
<div class="termine-card flex-none w-full md:w-1/2 bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl transform hover:scale-102 transition-all duration-300 ease-in-out border border-blue-200">
<!-- Inhalt der ersten Karte bleibt unverändert -->
@@ -66,7 +69,9 @@
<!-- Datumsanzeige -->
<div class="flex-grow flex items-center justify-center">
<div class="text-3xl font-bold text-sf_grau-800" data-day><?php echo htmlspecialchars($day); ?></div>
<div class="text-3xl font-bold text-sf_grau-800" data-day><?php echo htmlspecialchars(
$day,
); ?></div>
</div>
<!-- Dekorative Elemente - kleine Punkte für Kalendertage -->
@@ -81,8 +86,16 @@
</div>
</div>
<div class="ml-5 flex-grow">
<pack class="text-xl font-medium text-sf_grau-900"><?= $event["SUMMARY"] ?></p>
<p class="text-sf_blau-700 text-lg font-medium mt-1">ab <?php echo htmlspecialchars($time); ?> Uhr</p>
<pack class="text-xl font-medium text-sf_grau-900"><?= $event['SUMMARY'] ?></p>
<p class="text-sf_blau-700 text-lg font-medium mt-1">
<?php if ($date_info['has_time']): ?>
ab <span class="local-time" data-iso-date="<?php echo htmlspecialchars(
$iso_date,
); ?>"><?php echo htmlspecialchars($time); ?></span> Uhr
<?php else: ?>
ganztägig
<?php endif; ?>
</p>
</div>
</div>
</div>
@@ -169,6 +182,45 @@
// Initialer Aufruf
updateArrowState();
// Zeitzonenkonvertierung für lokale Zeiten
function convertTimesToLocal() {
const timeElements = document.querySelectorAll('.local-time');
timeElements.forEach(element => {
const isoDate = element.getAttribute('data-iso-date');
if (isoDate) {
try {
// Erstelle ein Date-Objekt aus der ISO-Date
// JavaScript erkennt automatisch UTC-Zeiten (mit Z oder +00:00)
const date = new Date(isoDate);
// Prüfe ob das Datum gültig ist
if (isNaN(date.getTime())) {
console.warn('Ungültiges Datum:', isoDate);
return;
}
// Formatiere die Zeit in der lokalen Zeitzone des Browsers
const localTime = date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
// Aktualisiere den Text
element.textContent = localTime;
// Debug-Information (kann später entfernt werden)
console.log('Konvertiert:', isoDate, '->', localTime, 'in Zeitzone:', Intl.DateTimeFormat().resolvedOptions().timeZone);
} catch (error) {
console.warn('Fehler bei der Zeitzonenkonvertierung:', error, 'für Datum:', isoDate);
}
}
});
}
// Zeitzonenkonvertierung beim Laden der Seite
convertTimesToLocal();
// Responsives Verhalten - bei Fenstergröße-Änderung
window.addEventListener('resize', () => {
// Anzahl der Karten je nach Bildschirmgröße anpassen
+134 -97
View File
@@ -4,77 +4,77 @@ $events = collection('termine');
// Nur Events mit DTSTART berücksichtigen
$events = array_filter($events, function ($event) {
return isset($event['DTSTART']) && ! empty($event['DTSTART']);
return isset($event['DTSTART']) && !empty($event['DTSTART']);
});
// Nach Datum sortieren
usort($events, function ($a, $b) {
return strcmp($a['DTSTART'], $b['DTSTART']);
return strcmp($a['DTSTART'], $b['DTSTART']);
});
// Aktuelles Datum
$today = (new DateTime())->format('Ymd');
$today = new DateTime()->format('Ymd');
// Nur zukünftige Termine anzeigen
$future_events = array_filter($events, function ($event) use ($today) {
return isset($event['DTSTART']) && $event['DTSTART'] >= $today;
return isset($event['DTSTART']) && $event['DTSTART'] >= $today;
});
// --- Deutsche Monatsnamen ---
$de_months = [
'01' => 'Januar',
'02' => 'Februar',
'03' => 'März',
'04' => 'April',
'05' => 'Mai',
'06' => 'Juni',
'07' => 'Juli',
'08' => 'August',
'09' => 'September',
'10' => 'Oktober',
'11' => 'November',
'12' => 'Dezember',
'01' => 'Januar',
'02' => 'Februar',
'03' => 'März',
'04' => 'April',
'05' => 'Mai',
'06' => 'Juni',
'07' => 'Juli',
'08' => 'August',
'09' => 'September',
'10' => 'Oktober',
'11' => 'November',
'12' => 'Dezember',
];
// --- Gruppierung aller Termine nach Jahr und Monat für die Sidebar ---
function group_events_by_year_month($events)
{
$grouped = [];
foreach ($events as $event) {
if ( ! isset($event['DTSTART'])) {
continue;
}
$date = $event['DTSTART'];
$year = substr($date, 0, 4);
$month = substr($date, 4, 2);
$grouped[$year][$month][] = $event;
}
krsort($grouped); // Jahre absteigend
foreach ($grouped as &$months) {
krsort($months); // Monate absteigend
$grouped = [];
foreach ($events as $event) {
if (!isset($event['DTSTART'])) {
continue;
}
$date = $event['DTSTART'];
$year = substr($date, 0, 4);
$month = substr($date, 4, 2);
$grouped[$year][$month][] = $event;
}
krsort($grouped); // Jahre absteigend
foreach ($grouped as &$months) {
krsort($months); // Monate absteigend
}
return $grouped;
return $grouped;
}
$all_events_grouped = group_events_by_year_month($events);
// --- Filter aus URL ---
$filter_jahr = $_GET['jahr'] ?? null;
$filter_jahr = $_GET['jahr'] ?? null;
$filter_monat = $_GET['monat'] ?? null;
if ($filter_jahr && $filter_monat) {
$filtered_events = array_filter($events, function ($event) use ($filter_jahr, $filter_monat) {
$date = $event['DTSTART'] ?? '';
$filtered_events = array_filter($events, function ($event) use ($filter_jahr, $filter_monat) {
$date = $event['DTSTART'] ?? '';
return substr($date, 0, 4) === $filter_jahr && substr($date, 4, 2) === $filter_monat;
});
return substr($date, 0, 4) === $filter_jahr && substr($date, 4, 2) === $filter_monat;
});
} elseif ($filter_jahr) {
$filtered_events = array_filter($events, function ($event) use ($filter_jahr) {
$date = $event['DTSTART'] ?? '';
$filtered_events = array_filter($events, function ($event) use ($filter_jahr) {
$date = $event['DTSTART'] ?? '';
return substr($date, 0, 4) === $filter_jahr;
});
return substr($date, 0, 4) === $filter_jahr;
});
} else {
$filtered_events = $future_events;
$filtered_events = $future_events;
}
?>
@@ -85,54 +85,43 @@ if ($filter_jahr && $filter_monat) {
<div class="bg-white rounded-lg shadow p-4">
<h2 class="text-lg font-semibold mb-3">Termine nach Jahr/Monat</h2>
<ul class="space-y-1">
<?php
foreach ($all_events_grouped as $year => $months): ?>
<?php foreach ($all_events_grouped as $year => $months): ?>
<li class="mb-2">
<div class="flex items-center">
<a href="?jahr=<?php
echo $year; ?>"
<a href="?jahr=<?php echo $year; ?>"
class="font-bold text-sf_blau-600 focus:outline-none flex items-center group<?php
if ($filter_jahr === $year && ! $filter_monat) {
echo ' underline';
} ?><?php
if ($filter_jahr === $year && ! $filter_monat) {
echo ' selected';
} ?>" onclick="event.stopPropagation(); openYear('<?php
echo $year; ?>')">
<span><?php
echo $year; ?></span>
if ($filter_jahr === $year && !$filter_monat) {
echo ' underline';
}
if ($filter_jahr === $year && !$filter_monat) {
echo ' selected';
}
?>" onclick="event.stopPropagation(); openYear('<?php echo $year; ?>')">
<span><?php echo $year; ?></span>
</a>
<button type="button" class="ml-1 focus:outline-none" onclick="toggleYear('<?php
echo $year; ?>')">
<svg class="w-4 h-4 transition-transform" id="arrow-<?php
echo $year; ?>" fill="none" stroke="currentColor" stroke-width="2"
<button type="button" class="ml-1 focus:outline-none" onclick="toggleYear('<?php echo $year; ?>')">
<svg class="w-4 h-4 transition-transform" id="arrow-<?php echo $year; ?>" fill="none" stroke="currentColor" stroke-width="2"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
<ul class="ml-4 mt-1 hidden" id="months-<?php
echo $year; ?>">
<?php
foreach ($months as $month => $evts): ?>
<ul class="ml-4 mt-1 hidden" id="months-<?php echo $year; ?>">
<?php foreach ($months as $month => $evts): ?>
<li>
<a href="?jahr=<?php
echo $year; ?>&monat=<?php
echo $month; ?>" class="text-sf_blau-500 hover:underline<?php
if ($filter_jahr === $year && $filter_monat === $month) {
echo ' font-bold underline selected';
} ?>">
<?php
echo $de_months[$month]; ?> (<?php
echo count($evts); ?>)
<a href="?jahr=<?php echo $year; ?>&monat=<?php echo $month; ?>" class="text-sf_blau-500 hover:underline<?php if (
$filter_jahr === $year &&
$filter_monat === $month
) {
echo ' font-bold underline selected';
} ?>">
<?php echo $de_months[$month]; ?> (<?php echo count($evts); ?>)
</a>
</li>
<?php
endforeach; ?>
<?php endforeach; ?>
</ul>
</li>
<?php
endforeach; ?>
<?php endforeach; ?>
</ul>
</div>
<script>
@@ -160,11 +149,9 @@ if ($filter_jahr && $filter_monat) {
</aside>
<!-- Hauptinhalt Termine -->
<div class="md:w-3/4 w-full">
<?php
if (empty($filtered_events)): ?>
<?php if (empty($filtered_events)): ?>
<div class="text-gray-500">Keine Termine gefunden.</div>
<?php
else: ?>
<?php else: ?>
<div class="overflow-x-auto">
<table class="min-w-full border border-gray-200 bg-white rounded-lg shadow">
<thead>
@@ -175,33 +162,40 @@ if ($filter_jahr && $filter_monat) {
</tr>
</thead>
<tbody>
<?php
foreach ($filtered_events as $event): ?>
<?php foreach ($filtered_events as $event): ?>
<?php
$start = $event['DTSTART'] ?? '';
$end = $event['DTEND'] ?? '';
$summary = $event['SUMMARY'] ?? '';
$start = $event['DTSTART'] ?? '';
$end = $event['DTEND'] ?? '';
$summary = $event['SUMMARY'] ?? '';
$location = $event['LOCATION'] ?? '';
$desc = $event['DESCRIPTION'] ?? '';
$date = format_ics_date($start);
$time = (str_contains($start, 'T')) ? substr($date, 11) : 'ganztägig';
$date = substr($date, 0, 10);
$desc = $event['DESCRIPTION'] ?? '';
$timezone = $event['DTSTART_TZID'] ?? null;
$date_info = format_ics_date_with_timezone($start, $timezone);
$date = $date_info['display'];
$time = $date_info['has_time'] ? substr($date, 11) : 'ganztägig';
$date = substr($date, 0, 10);
$iso_date = $date_info['iso'];
?>
<tr class="hover:bg-gray-50">
<td class="py-2 px-4 border-b whitespace-nowrap"><?php
echo htmlspecialchars($date); ?></td>
<td class="py-2 px-4 border-b whitespace-nowrap"><?php
echo htmlspecialchars($time); ?></td>
<td class="py-2 px-4 border-b"><?php
echo htmlspecialchars($summary); ?></td>
<td class="py-2 px-4 border-b whitespace-nowrap"><?php echo htmlspecialchars(
$date,
); ?></td>
<td class="py-2 px-4 border-b whitespace-nowrap">
<?php if ($date_info['has_time']): ?>
<span class="local-time" data-iso-date="<?php echo htmlspecialchars(
$iso_date,
); ?>"><?php echo htmlspecialchars($time); ?></span>
<?php else: ?>
ganztägig
<?php endif; ?>
</td>
<td class="py-2 px-4 border-b"><?php echo htmlspecialchars($summary); ?></td>
</tr>
<?php
endforeach; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
endif; ?>
<?php endif; ?>
</div>
</div>
<style>
@@ -211,4 +205,47 @@ if ($filter_jahr && $filter_monat) {
font-weight: bold;
}
</style>
<script>
// Zeitzonenkonvertierung für lokale Zeiten
function convertTimesToLocal() {
const timeElements = document.querySelectorAll('.local-time');
timeElements.forEach(element => {
const isoDate = element.getAttribute('data-iso-date');
if (isoDate) {
try {
// Erstelle ein Date-Objekt aus der ISO-Date
// JavaScript erkennt automatisch UTC-Zeiten (mit Z oder +00:00)
const date = new Date(isoDate);
// Prüfe ob das Datum gültig ist
if (isNaN(date.getTime())) {
console.warn('Ungültiges Datum:', isoDate);
return;
}
// Formatiere die Zeit in der lokalen Zeitzone des Browsers
const localTime = date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
// Aktualisiere den Text
element.textContent = localTime;
// Debug-Information (kann später entfernt werden)
console.log('Konvertiert:', isoDate, '->', localTime, 'in Zeitzone:', Intl.DateTimeFormat().resolvedOptions().timeZone);
} catch (error) {
console.warn('Fehler bei der Zeitzonenkonvertierung:', error, 'für Datum:', isoDate);
}
}
});
}
// Zeitzonenkonvertierung beim Laden der Seite
document.addEventListener('DOMContentLoaded', function() {
convertTimesToLocal();
});
</script>
</section>