From c31a16cb6d9057883cc5f46376c190a8a8736546 Mon Sep 17 00:00:00 2001
From: Anton Schubert
Date: Sat, 31 Oct 2020 20:43:09 +0100
Subject: preserve schedule timezones and show current time + event timezone on
 the website (#117)

---
 assets/css/_schedule.less          |  24 ++---
 assets/css/_structure.less         |   5 +
 assets/js/lustiges-script.js       |  44 +++++++--
 model/Schedule.php                 | 195 +++++++++++++++++--------------------
 template/assemblies/header.phtml   |   6 ++
 template/assemblies/schedule.phtml |  13 ++-
 6 files changed, 161 insertions(+), 126 deletions(-)

diff --git a/assets/css/_schedule.less b/assets/css/_schedule.less
index fe512a9..852d5a4 100644
--- a/assets/css/_schedule.less
+++ b/assets/css/_schedule.less
@@ -16,19 +16,21 @@ body .schedule {
 
 	.now {
 		position: absolute;
-		left: 0;
-		width: 150px;
-		height: 100%;
-		background-color: @schedule-now-bg;
-		font-size: 14px;
 		pointer-events: none;
-
+		height: 100%;
+		display: flex;
+		left: 0;
 		z-index: 5;
 
-		span {
-			display: block;
-			position: absolute;
-			right: -28px;
+		.overlay {
+			width: 150px;
+			height: 100%;
+			background-color: @schedule-now-bg;
+		}
+
+		.label {
+			font-size: 14px;
+			padding-left: 5px;
 			color: @schedule-now;
 		}
 	}
@@ -50,7 +52,7 @@ body .schedule {
 
 		.inner {
 			display: block;
-			padding: 10px;
+			padding: 15px;
 			height: 100%;
 		}
 
diff --git a/assets/css/_structure.less b/assets/css/_structure.less
index bf9e3b1..eeb1b23 100644
--- a/assets/css/_structure.less
+++ b/assets/css/_structure.less
@@ -55,6 +55,11 @@ nav {
 		}
 	}
 
+	.navbar-time {
+		line-height: 27px;
+		padding: 10px 10px;
+	}
+
 	.button-wrapper > .btn {
 		width: 40px;
 	}
diff --git a/assets/js/lustiges-script.js b/assets/js/lustiges-script.js
index 67ecd04..b1664f3 100644
--- a/assets/js/lustiges-script.js
+++ b/assets/js/lustiges-script.js
@@ -127,6 +127,7 @@ $(function() {
 $(function() {
 	var
 		$schedule = $('body .schedule'),
+		$time = $('.navbar-time'),
 		$now = $schedule.find('.now'),
 		scrollLock = false,
 		rewindTimeout,
@@ -154,6 +155,23 @@ $(function() {
 		}
 	});
 
+	function formatLocalTime(timestamp, offset) {
+		const d = new Date(timestamp * 1000);
+
+		// js timezone offset is negative
+		const diff = -d.getTimezoneOffset() - offset;
+		d.setUTCMinutes(d.getUTCMinutes() - diff);
+
+		return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
+	}
+
+	function formatOffset(offset) {
+		const sign = offset < 0 ? "-" : "+";
+		const hours = String(Math.floor(Math.abs(offset)/60)).padStart(2, '0');
+		const minutes = String(Math.abs(offset)%60).padStart(2, '0');
+		return sign + hours + ":" + minutes;
+	}
+
 	// schedule now-marker & scrolling
 	function updateProgramView(initial) {
 		var
@@ -167,12 +185,22 @@ $(function() {
 			.find('.room')
 			.first()
 			.find('.block')
-			.filter(function(i, el) { 
+			.filter(function(i, el) {
 				return $(this).data('start') < now;
 			}).last();
 
-		if($block.length == 0)
-			return $now.css('width', 0);
+		// if we are yet to start find first block as reference
+		if (!$block.length)
+			$block = $schedule
+				.find('.room').first()
+				.find('.block').first();
+
+		// still no luck
+		if(!$block.length) {
+			$now.find('.overlay').css('width', 0);
+			$now.find('.label').text('now');
+			return;
+		}
 
 		var
 			// start & end-timestamp
@@ -195,15 +223,19 @@ $(function() {
 			px_in_display = px - scrollx;
 
 		//console.log($block.get(0), new Date(start*1000), new Date(now*1000), new Date(end*1000), normalized, px);
-		$now.css('width', px);
+		const eventOffset = parseFloat($block.data('offset')) || 0;
+		const eventTime = formatLocalTime(now, eventOffset);
+		$now.find('.overlay').css('width', px);
+		$now.find('.label').text("now (" + eventTime + ")");
+		$time.text(eventTime + " (" + formatOffset(eventOffset) + ")");
 
 		// scrolling is locked by manual interaction
-		if(scrollLock)
+		if (scrollLock)
 			return;
 
 		if(
 			// now marker is > 2/3 of the schedule-display-width
-			px_in_display > (displayw * 2/3) || 
+			px_in_display > (displayw * 2/3) ||
 
 			// now marker is <1/7 of the schedule-display-width
 			px_in_display < (displayw/7)
diff --git a/model/Schedule.php b/model/Schedule.php
index d006d1f..739d2c8 100644
--- a/model/Schedule.php
+++ b/model/Schedule.php
@@ -89,31 +89,32 @@ class Schedule
 		// so to be on the safer side we calculate our own daystart/end here
 		foreach($schedule->day as $day)
 		{
-			$daystart = PHP_INT_MAX;
-			$dayend = 0;
+			$daystart = new DateTimeImmutable('+100 year');
+			$dayend = new DateTimeImmutable('-100 year');
 
 			foreach($day->room as $room)
 			{
 				$roomName = (string)$room['name'];
 				if($this->isRoomFiltered($roomName))
 					continue;
-				
+
 				if(!in_array($roomName, $rooms))
 					$rooms[] = $roomName;
 
 				foreach($room->event as $event)
 				{
-					$start = strtotime((string)$event->date);
-					$duration = $this->strToDuration((string)$event->duration);
-					$end = $start + $duration;
+					$start = new DateTimeImmutable((string)$event->date);
+					$interval = $this->strToInterval((string)$event->duration);
+					$end = $start->add($interval);
 
-					$daystart = min($daystart, $start);
-					$dayend = max($dayend, $end);
+					$daystart = $start < $daystart ? $start : $daystart;
+					$dayend = $end > $dayend ? $end : $dayend;
 				}
 			}
 
-			$day['start'] = $daystart;
-			$day['end'] = $dayend;
+			// stringify again to store in simplexml
+			$day['start'] = $daystart->format('c');
+			$day['end'] = $dayend->format('c');
 		}
 
 
@@ -123,23 +124,23 @@ class Schedule
 			$daysSorted[] = $day;
 		}
 
-		usort($daysSorted, function($a, $b) {
-			return (int)$a['start'] - (int)$b['start'];
+		usort($daysSorted, function($a, $b): int {
+			return strcmp($a['start'], $b['start']);
 		});
 
 		$dayidx = 0;
 		foreach($daysSorted as $day)
 		{
 			$dayidx++;
-			$daystart = (int)$day['start'];
-			$dayend = (int)$day['end'];
+			$daystart = new DateTimeImmutable($day['start']);
+			$dayend = new DateTimeImmutable($day['end']);
 
 			$roomidx = 0;
 			foreach($rooms as $roomName)
 			{
 				$roomidx++;
-				$laststart = false;
-				$lastend = false;
+				$laststart = NULL;
+				$lastend = NULL;
 
 				if($this->isRoomFiltered($roomName))
 					continue;
@@ -147,24 +148,16 @@ class Schedule
 				$result = $day->xpath("room[@name='".$roomName."']");
 				if(!$result) {
 					// this room has no events on this day -> add long gap
-					$program[$roomName][] = array(
-						'special' => 'gap',
-
-						'fstart' => date('c', $daystart),
-						'fend' => date('c', $dayend),
-
-						'start' => $daystart,
-						'end' => $dayend,
-						'duration' => $dayend - $daystart,
-					);
-					$program[$roomName][] = array(
-						'special' => 'daychange',
-						'title' => 'Daychange from Day '.$dayidx.' to '.($dayidx+1),
-
-						'start' => $dayend,
-						'end' => (int)$schedule->day[$dayidx]['start'],
-						'duration' => 60*60,
-					);
+					$gap = $this->makeEvent($daystart, $dayend);
+					$gap['special'] = 'gap';
+					$program[$roomName][] = $gap;
+
+					$end = new DateTimeImmutable($schedule->day[$dayidx]['start']);
+					$daychange = $this->makeEvent($dayend, $end);
+					$daychange['special'] = 'daychange';
+					$daychange['title'] = 'Daychange from Day '.$dayidx.' to '.($dayidx+1);
+					$daychange['duration'] = 3600;
+					$program[$roomName][] = $daychange;
 					continue;
 				}
 				$room = $result[0];
@@ -184,103 +177,74 @@ class Schedule
 
 				foreach($eventsSorted as $event)
 				{
-					$start = strtotime((string)$event->date);
-					$duration = $this->strToDuration((string)$event->duration);
-					$end = $start + $duration;
+					$start = new DateTimeImmutable((string)$event->date);
+					$interval = $this->strToInterval((string)$event->duration);
+					$end = $start->add($interval);
 
 					// skip duplicate events in fahrplan source
 					if ( $laststart == $start )
-						continue;
+					continue;
 
 					if($lastend && $lastend < $start)
 					{
-						// synthesize pause event
-						$pauseduration = $start - $lastend;
-						$program[$roomName][] = array(
-							'special' => 'pause',
-							'title' => round($pauseduration / 60).' minutes pause',
-
-							'fstart' => date('c', $lastend),
-							'fend' => date('c', $start),
-
-							'start' => $lastend,
-							'end' => $start,
-							'duration' => $pauseduration,
-							'room_known' => $this->isRoomMapped($roomName),
-						);
+						// pause between talks
+						$pause = $this->makeEvent($lastend, $start);
+						$pause['special'] = 'pause';
+						$pause['title'] = round($pause['duration'] / 60).' minutes pause';
+						$pause['room_known'] = $this->isRoomMapped($roomName);
+						$program[$roomName][] = $pause;
 					}
 					else if(!$lastend && $daystart < $start)
 					{
-						$program[$roomName][] = array(
-							'special' => 'gap',
-
-							'fstart' => date('c', $daystart),
-							'fend' => date('c', $start),
-
-							'start' => $daystart,
-							'end' => $start,
-							'duration' => $start - $daystart,
-							'room_known' => $this->isRoomMapped($roomName),
-						);
+						// gap before first talk
+						$gap = $this->makeEvent($daystart, $start);
+						$gap['special'] = 'gap';
+						$gap['room_known'] = $this->isRoomMapped($roomName);
+						$program[$roomName][] = $gap;
 					}
 
 					$personnames = array();
 					if(isset($event->persons)) foreach($event->persons->person as $person)
 						$personnames[] = (string)$person;
 
-					$program[$roomName][] = array(
-						'title' => (string)$event->title,
-						'speaker' => implode(', ', $personnames),
-
-						'fstart' => date('c', $start),
-						'fend' => date('c', $end),
-
-						'start' => $start,
-						'end' => $end,
-						'duration' => $duration,
-						'room_known' => $this->isRoomMapped($roomName),
-						'optout' => $this->isOptout($event),
-					);
+					// normal talk
+					$talk = $this->makeEvent($start, $end);
+					$talk['title'] = (string)$event->title;
+					$talk['speaker'] = implode(', ', $personnames);
+					$talk['room_known'] = $this->isRoomMapped($roomName);
+					$talk['optout'] = $this->isOptout($event);
+					$program[$roomName][] = $talk;
 
 					$laststart = $start;
 					$lastend = $end;
 				}
 
-				// synthesize daychange event
 				if(!$lastend) $lastend = $daystart;
 				if($lastend < $dayend)
 				{
-					$program[$roomName][] = array(
-						'special' => 'gap',
-
-						'fstart' => date('c', $lastend),
-						'fend' => date('c', $dayend),
-
-						'start' => $lastend,
-						'end' => $dayend,
-						'duration' => $dayend - $lastend,
-					);
+					// gap after last talk
+					$gap = $this->makeEvent($lastend, $dayend);
+					$gap['special'] = 'gap';
+					$program[$roomName][] = $gap;
 				}
 
 				if($dayidx < count($schedule->day))
 				{
-					$program[$roomName][] = array(
-						'special' => 'daychange',
-						'title' => 'Daychange from Day '.$dayidx.' to '.($dayidx+1),
-
-						'start' => $dayend,
-						'end' => (int)$schedule->day[$dayidx]['start'],
-						'duration' => 60*60,
-					);
+					// daychange
+					$end = new DateTimeImmutable($schedule->day[$dayidx]['start']);
+					$daychange = $this->makeEvent($dayend, $end);
+					$daychange['special'] = 'daychange';
+					$daychange['title'] = 'Daychange from Day '.$dayidx.' to '.($dayidx+1);
+					$daychange['duration'] = 3600;
+					$program[$roomName][] = $daychange;
 				}
 			}
 		}
 
-
 		$mapping = $this->getScheduleToRoomSlugMapping();
 		if($this->getConference()->has('SCHEDULE.ROOMFILTER'))
 		{
-			// sort by roomfilter
+			// determine roomfilter
 			$roomfilter = $this->getConference()->get('SCHEDULE.ROOMFILTER');
 
 			// map roomfilter-rooms to room-slugs
@@ -291,7 +255,7 @@ class Schedule
 				return $e;
 			}, $roomfilter);
 
-			// sort according to roomtilter ordering
+			// sort according to roomfilter ordering
 			uksort($program, function($a, $b) use ($roomfilter) {
 				return array_search($a, $roomfilter) - array_search($b, $roomfilter);
 			});
@@ -300,6 +264,32 @@ class Schedule
 		return $program;
 	}
 
+	private function makeEvent(DateTimeImmutable $from, DateTimeImmutable $to): array {
+		return array(
+			'fstart' => $from->format('c'),
+			'fend' => $to->format('c'),
+			'tstart' => $from->format('H:i'),
+			'tend' => $to->format('H:i'),
+
+			'start' => $from->getTimestamp(),
+			'end' => $to->getTimestamp(),
+			'offset' => $from->getOffset(),
+			'duration' => $to->getTimestamp() - $from->getTimestamp(),
+		);
+	}
+
+	private function intervalToDuration(DateInterval $interval): int {
+		$one = new DateTimeImmutable();
+		$two = $one->add($interval);
+		return $two->getTimestamp() - $one->getTimestamp();
+	}
+
+	private function strToInterval(string $str): DateInterval
+	{
+		$parts = explode(':', $str);
+		return new DateInterval('PT'.$parts[0].'H'.$parts[1].'M');
+	}
+
 
 	public function getDurationSum()
 	{
@@ -312,23 +302,18 @@ class Schedule
 	}
 
 
-
-	private function strToDuration($str)
-	{
-		$parts = explode(':', $str);
-		return ((int)$parts[0] * 60 + (int)$parts[1]) * 60;
-	}
-
 	public function getScheduleUrl()
 	{
 		return $this->getConference()->get('SCHEDULE.URL');
 	}
 
+
 	public function getScheduleCache()
 	{
 		return sprintf('/tmp/schedule-cache-%s.xml', $this->getConference()->getSlug());
 	}
 
+
 	public function getScheduleToRoomSlugMapping()
 	{
 		$mapping = array();
diff --git a/template/assemblies/header.phtml b/template/assemblies/header.phtml
index 43c6494..f7051f1 100644
--- a/template/assemblies/header.phtml
+++ b/template/assemblies/header.phtml
@@ -22,6 +22,12 @@
 				<span class="fa fa-info"></span>
 			</a>
 		</div>
+
+		<? if(isset($room) && $room->hasSchedule()): ?>
+			<div class="navbar-right navbar-time">
+				Current Time
+			</div>
+		<? endif ?>
 	</div>
 </nav>
 
diff --git a/template/assemblies/schedule.phtml b/template/assemblies/schedule.phtml
index fe0ccbb..2680a44 100644
--- a/template/assemblies/schedule.phtml
+++ b/template/assemblies/schedule.phtml
@@ -1,10 +1,14 @@
 <div class="schedule scroll-container">
 	<div class="scroll-element">
-		<div class="now"><span>now</span></div>
+		<? $totalWidth = round($schedule->getDurationSum() / $schedule->getScale()) ?>
+		<div class="now" style="width: <?= h($totalWidth) ?>px">
+			<div class="overlay"></div>
+			<div class="label">now</div>
+		</div>
 		<? $rooms = $schedule->getSchedule() ?>
 		<? foreach($rooms as $roomname => $events): ?>
 			<? $scheduleRoom = $schedule->getMappedRoom($roomname) ?>
-			<div class="room <? if(isset($room) && $roomname == $room->getScheduleName()): ?>highlight<? endif ?>" style="width: <?=round($schedule->getDurationSum() / $schedule->getScale())?>px">
+			<div class="room <? if(isset($room) && $roomname == $room->getScheduleName()): ?>highlight<? endif ?>" style="width: <?= h($totalWidth) ?>px">
 				<? $fromstart = 0; ?>
 				<? foreach($events as $event): ?>
 					<div
@@ -12,6 +16,7 @@
 						style="width: <?=h(round($event['duration'] / $schedule->getScale()))?>px; left: <?=h(round($fromstart / $schedule->getScale()))?>px"
 						data-start="<?=intval($event['start'])?>"
 						data-end="<?=intval($event['end'])?>"
+						data-offset="<?=intval($event['offset']/60)?>"
 					>
 						<? $fromstart += $event['duration'] ?>
 						<? if($scheduleRoom): ?>
@@ -42,9 +47,9 @@
 
 							<? else: ?>
 								<? if($event['duration'] > 10*60): /* only display when event is longer as 10 minutes */ ?>
-									<h4><?=h(strftime('%H:%M', $event['start']))?>
+									<h4><?=h($event['tstart'])?>
 										&ndash;
-										<?=h(strftime('%H:%M', $event['end']))?>
+										<?=h($event['tend'])?>
 										&nbsp;in&nbsp;
 										<?=h($scheduleRoom ? $scheduleRoom->getDisplayShort() : $roomname) ?>
 									</h4>
-- 
cgit v1.2.3