modify(‘+1 day’);
$todayEvents = [];
foreach ($events as $e) {
// Consider events that overlap today:
// start < tomorrow AND end > todayStart
$start = $e[‘start’];
$end = $e[‘end’];
if ($start < $tomorrowStart && $end > $todayStart) {
$todayEvents[] = $e;
}
}
// Sort by start time
usort($todayEvents, function ($a, $b) {
return $a[‘start’] <=> $b[‘start’];
});
// Limit
if (count($todayEvents) > $MAX_EVENTS) {
$todayEvents = array_slice($todayEvents, 0, $MAX_EVENTS);
}
// ————————-
// RENDER HTML
// ————————-
$html = render_today_html($todayEvents, $todayStart, $TZ);
// Write cache
@file_put_contents($CACHE_FILE, $html);
header(‘Content-Type: text/html; charset=utf-8’);
echo $html;
exit;
// ============================================================
// FUNCTIONS
// ============================================================
function fetch_ics(string $url): ?string
{
// Use cURL if available (more reliable), else fallback to file_get_contents
if (function_exists(‘curl_init’)) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_USERAGENT => ‘TV-Calendar-Today/1.0’,
]);
$data = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($data !== false && $code >= 200 && $code < 300) {
return (string)$data;
}
return null;
}
$context = stream_context_create([
'http' => [
‘timeout’ => 10,
‘header’ => “User-Agent: TV-Calendar-Today/1.0\r\n”,
],
]);
$data = @file_get_contents($url, false, $context);
return $data === false ? null : (string)$data;
}
/**
* Parse ICS and return array of events:
* [
* [‘title’ => string, ‘start’ => DateTimeImmutable, ‘end’ => DateTimeImmutable, ‘allDay’ => bool]
* ]
*
* Notes:
* – Handles basic VEVENT fields only (SUMMARY, DTSTART, DTEND)
* – Handles folded lines
* – Handles DTSTART/DTEND formats:
* – DATE: 20260127 (all-day)
* – DATE-TIME UTC: 20260127T090000Z
* – DATE-TIME local: 20260127T090000
* – TZID param: DTSTART;TZID=Europe/London:20260127T090000
* – Does NOT fully expand RRULE recurrences (kept simple).
*/
function parse_ics_events(string $ics, DateTimeZone $displayTz): array
{
$lines = unfold_ics_lines($ics);
$events = [];
$inEvent = false;
$cur = [
‘title’ => ”,
‘dtstart_raw’ => null,
‘dtend_raw’ => null,
‘dtstart_params’ => [],
‘dtend_params’ => [],
];
foreach ($lines as $line) {
$line = trim($line);
if ($line === ‘BEGIN:VEVENT’) {
$inEvent = true;
$cur = [
‘title’ => ”,
‘dtstart_raw’ => null,
‘dtend_raw’ => null,
‘dtstart_params’ => [],
‘dtend_params’ => [],
];
continue;
}
if ($line === ‘END:VEVENT’) {
if ($inEvent && $cur[‘dtstart_raw’] !== null) {
$start = parse_ical_datetime($cur[‘dtstart_raw’], $cur[‘dtstart_params’], $displayTz);
$end = $cur[‘dtend_raw’] !== null
? parse_ical_datetime($cur[‘dtend_raw’], $cur[‘dtend_params’], $displayTz)
: $start->modify(‘+1 hour’);
// If DTSTART was DATE (all-day), DTEND is usually next day; keep as-is.
$allDay = is_ical_date_only($cur[‘dtstart_raw’], $cur[‘dtstart_params’]);
$events[] = [
‘title’ => $cur[‘title’] !== ” ? $cur[‘title’] : ‘(No title)’,
‘start’ => $start,
‘end’ => $end,
‘allDay’=> $allDay,
];
}
$inEvent = false;
continue;
}
if (!$inEvent) continue;
// Split “KEY;PARAMS:VALUE” format
[$left, $value] = split_ics_key_value($line);
if ($left === null) continue;
[$key, $params] = parse_ics_key_and_params($left);
$key = strtoupper($key);
if ($key === ‘SUMMARY’) {
$cur[‘title’] = unescape_ics_text($value);
} elseif ($key === ‘DTSTART’) {
$cur[‘dtstart_raw’] = $value;
$cur[‘dtstart_params’] = $params;
} elseif ($key === ‘DTEND’) {
$cur[‘dtend_raw’] = $value;
$cur[‘dtend_params’] = $params;
}
}
return $events;
}
function unfold_ics_lines(string $ics): array
{
// Normalize newlines
$ics = str_replace([“\r\n”, “\r”], “\n”, $ics);
$rawLines = explode(“\n”, $ics);
$lines = [];
foreach ($rawLines as $line) {
if ($line === ”) continue;
// Folded line starts with a space or tab and continues previous line
if (!empty($lines) && (isset($line[0]) && ($line[0] === ‘ ‘ || $line[0] === “\t”))) {
$lines[count($lines) – 1] .= substr($line, 1);
} else {
$lines[] = $line;
}
}
return $lines;
}
function split_ics_key_value(string $line): array
{
$pos = strpos($line, ‘:’);
if ($pos === false) return [null, null];
$left = substr($line, 0, $pos);
$value = substr($line, $pos + 1);
return [$left, $value];
}
function parse_ics_key_and_params(string $left): array
{
$parts = explode(‘;’, $left);
$key = array_shift($parts);
$params = [];
foreach ($parts as $p) {
$eq = strpos($p, ‘=’);
if ($eq === false) continue;
$k = strtoupper(substr($p, 0, $eq));
$v = substr($p, $eq + 1);
$params[$k] = $v;
}
return [$key, $params];
}
function is_ical_date_only(string $value, array $params): bool
{
// Either explicit VALUE=DATE or value length 8 (YYYYMMDD)
if (isset($params[‘VALUE’]) && strtoupper($params[‘VALUE’]) === ‘DATE’) return true;
return (bool)preg_match(‘/^\d{8}$/’, $value);
}
function parse_ical_datetime(string $value, array $params, DateTimeZone $displayTz): DateTimeImmutable
{
// DATE (all-day)
if (is_ical_date_only($value, $params)) {
// All-day starts at midnight in display timezone
$dt = DateTimeImmutable::createFromFormat(‘!Ymd’, $value, $displayTz);
return $dt ?: new DateTimeImmutable(‘now’, $displayTz);
}
// TZID param (local time in that TZ)
if (isset($params[‘TZID’])) {
$tz = new DateTimeZone($params[‘TZID’]);
$dt = DateTimeImmutable::createFromFormat(‘!Ymd\THis’, $value, $tz);
if ($dt === false) {
// Some feeds omit seconds: Ymd\THi
$dt = DateTimeImmutable::createFromFormat(‘!Ymd\THi’, $value, $tz);
}
return ($dt ?: new DateTimeImmutable(‘now’, $displayTz))->setTimezone($displayTz);
}
// UTC Z suffix
if (str_ends_with($value, ‘Z’)) {
$v = substr($value, 0, -1);
$utc = new DateTimeZone(‘UTC’);
$dt = DateTimeImmutable::createFromFormat(‘!Ymd\THis’, $v, $utc);
if ($dt === false) $dt = DateTimeImmutable::createFromFormat(‘!Ymd\THi’, $v, $utc);
return ($dt ?: new DateTimeImmutable(‘now’, $displayTz))->setTimezone($displayTz);
}
// Floating local time (assume display TZ)
$dt = DateTimeImmutable::createFromFormat(‘!Ymd\THis’, $value, $displayTz);
if ($dt === false) $dt = DateTimeImmutable::createFromFormat(‘!Ymd\THi’, $value, $displayTz);
return $dt ?: new DateTimeImmutable(‘now’, $displayTz);
}
function unescape_ics_text(string $text): string
{
// ICS escaping: \n, \, \;, \,
$text = str_replace([‘\\n’, ‘\\N’], “\n”, $text);
$text = str_replace([‘\\,’], ‘,’, $text);
$text = str_replace([‘\\;’], ‘;’, $text);
$text = str_replace([‘\\\\’], ‘\\’, $text);
return $text;
}
function render_today_html(array $events, DateTimeImmutable $todayStart, DateTimeZone $tz): string
{
$dateLabel = $todayStart->format(‘l j F Y’); // British style
$items = ”;
if (count($events) === 0) {
$items = ‘
‘;
} else {
foreach ($events as $e) {
$title = htmlspecialchars($e[‘title’], ENT_QUOTES | ENT_SUBSTITUTE, ‘UTF-8’);
if ($e[‘allDay’]) {
$time = ‘All day’;
} else {
$time = format_time_range($e[‘start’], $e[‘end’], $tz);
}
$items .= ‘
. ‘‘ . $title . ‘
‘;
}
}
// Minimal CSS for TV readability
return ‘
Today
- ‘ . $items . ‘
‘;
}
function format_time_range(DateTimeImmutable $start, DateTimeImmutable $end, DateTimeZone $tz): string
{
$fmt = new IntlDateFormatter(
‘en_GB’,
IntlDateFormatter::NONE,
IntlDateFormatter::SHORT,
$tz->getName(),
IntlDateFormatter::GREGORIAN,
‘HH:mm’
);
$s = $fmt->format($start);
$e = $fmt->format($end);
// If end equals start (rare), show just one time
if ($s === $e) return (string)$s;
return $s . ‘–’ . $e;
}
function render_error(string $message): string
{
$msg = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, ‘UTF-8’);
return ‘
. ‘
‘ . $msg . ‘
‘
. ‘‘;
}