Fix schedules around midnight

Around midnight the app didn't work well because it didn't retrieve the
correct schedules. Also, the way the frontend was architectured, trains
which were scheduled after midnight appeared for 1 second and then
disappeared.

This CL fixes this by changing the following:

- The API now returns arrival and departure times by specifying a UNIX
  timestamp instead of the seconds since midnight. This helps simplify
  the code in the frontend.
- Adapting the frontend to consume the new timestamps, to fix the
  disappearing schedule bug.
- Fixing the SQL query to get the stop times, since it didn't retrieve
  the correct rows.

Fixed: misc:17

Change-Id: I98f1fe20d44163e716d4f57a2018635be74526d0
diff --git a/inc/api.php b/inc/api.php
index 55daf73..ba3ec2f 100644
--- a/inc/api.php
+++ b/inc/api.php
@@ -69,18 +69,51 @@
       $stop = $gtfs->getStop($_GET["stop"]);
       $times = $gtfs->getStopTimes($_GET["stop"]);
 
+      $todayTimestamp = (new DateTime("today"))->getTimestamp();
+      $nowTimestamp = (new DateTime("now"))->getTimestamp();
+
       $schedules = [];
       $routes = [];
       foreach ($times as $time) {
         if ($time["trip_headsign"] == $stop["stop_name"]) continue;
-        $schedules[] = [
+
+        if ($time["date"]) {
+          // In this case the train was specifically scheduled for this date,
+          // so we are given the exact date.
+          $date = new DateTime($time["date"]);
+          $arrivalTime = gtfs::time2seconds($time["arrival_time"], false);
+          $departureTime = gtfs::time2seconds($time["departure_time"], false);
+          while ($departureTime >= 24*60*60) {
+            $arrivalTime -= 24*60*60;
+            $departureTime -= 24*60*60;
+            $date->add(new DateInterval("P1D"));
+          }
+
+          $dayTimestamp = $date->getTimestamp();
+        } else {
+          // In this case the train was scheduled several days, so we'll check
+          // whether the train has already passed today (in which case it will
+          // pass tomorrow) or it hasn't (in which case it will pass today).
+          $arrivalTime = gtfs::time2seconds($time["arrival_time"], true);
+          $departureTime = gtfs::time2seconds($time["departure_time"], true);
+          if ($todayTimestamp + $departureTime >= $nowTimestamp)
+            $dayTimestamp = $todayTimestamp;
+          else
+            $dayTimestamp = $todayTimestamp->add(new DateInterval("P1D"));
+        }
+
+        $schedule = [
           "destination" => $time["trip_headsign"],
-          "arrivalTime" => gtfs::time2seconds($time["arrival_time"]),
-          "departureTime" => gtfs::time2seconds($time["departure_time"]),
+          "arrivalTime" => $dayTimestamp + $arrivalTime,
+          "departureTime" => $dayTimestamp + $departureTime,
           "route" => self::transformRouteShortName($time["route_short_name"]),
           "color" => $time["route_color"],
           "textColor" => $time["route_text_color"]
         ];
+        if (isset($_GET["includeSqlRows"]))
+          $schedule["originalSqlRow"] = $time;
+
+        $schedules[] = $schedule;
 
         if (!in_array($time["route_short_name"], $routes)) $routes[] = $time["route_short_name"];
       }
diff --git a/inc/gtfs.php b/inc/gtfs.php
index ae33d50..c774ad3 100644
--- a/inc/gtfs.php
+++ b/inc/gtfs.php
@@ -25,13 +25,18 @@
     return date("Ymd");
   }
 
-  static function time2seconds($time) {
+  // Converts a time in the format "HH:MM:SS" to the number of seconds. If
+  // |uniqueRepresentative| is true, when HH >= 24, a representative between 0
+  // and 24*60*60 - 1 of the equivalence class mod 24*60*60 will be returned
+  // instead of returning a number >= 24*60*60.
+  static function time2seconds($time, $uniqueRepresentative=true) {
     $timeSinceMidnight = self::timeSinceMidnight();
 
     $boom = explode(":", $time);
     if (count($boom) != 3) return null;
 
-    return ((($boom[0]*60) + $boom[1])*60 + $boom[2]) % (24*60*60);
+    $seconds = (($boom[0]*60) + $boom[1])*60 + $boom[2];
+    return $uniqueRepresentative ? $seconds % (24*60*60) : $seconds;
   }
 
   private function fetchAll($sql) {
@@ -104,9 +109,11 @@
     $stopParameters = array_keys($values);
 
     $rdow = (int)(new DateTime("now"))->format("w");
+    $dow0 = self::$dow[($rdow - 1) % 7]; // Yesterday's day of week
     $dow = self::$dow[$rdow]; // Today's day of week
     $dow2 = self::$dow[($rdow + 1) % 7]; // Tomorrow's day of week
 
+    $values[":yesterday"] = (int)(new DateTime("yesterday"))->format("Ymd"); // Yesterday's date
     $values[":today"] = (int)(new DateTime("now"))->format("Ymd"); // Today's date
     $values[":tomorrow"] = (int)(new DateTime("tomorrow"))->format("Ymd"); // Tomorrow's date
     $values[":now"] = (new DateTime("now"))->format("H:i:s");
@@ -138,7 +145,7 @@
             ) OR
             (
               time(:now) >= time('00:00:00', '-".(int)$timeLimit." minutes') AND
-              st.departure_time BETWEEN time(:now) AND strftime('24:%M:%S', :now, '".(int)$timeLimit." minutes')
+              st.departure_time BETWEEN time(:now) AND ((strftime('%H', :now, '".(int)$timeLimit." minutes') + 24) || strftime(':%M:%S', :now, '".(int)$timeLimit." minutes'))
             )
           ) AND
           (
@@ -162,12 +169,28 @@
             ) OR
             (
               time(:now) >= time('00:00:00', '-".(int)$timeLimit." minutes') AND
-              st.departure_time BETWEEN time(:now) AND strftime('24:%M:%S', :now, '".(int)$timeLimit." minutes')
+              st.departure_time BETWEEN ((strftime('%H', :now) + 24) || strftime(':%M:%S', :now)) AND ((strftime('%H', :now, '".(int)$timeLimit." minutes') + 48) || strftime(':%M:%S', :now, '".(int)$timeLimit." minutes'))
             )
           ) AND
           (
             c.service_id IS NULL OR
             (
+              c.start_date <= :yesterday AND
+              c.end_date >= :yesterday AND
+              c.$dow0 = ".(int)Gtfs\Calendar\CalendarDay::AVAILABLE."
+            )
+          ) AND
+          (
+            cd.service_id IS NULL OR
+            cd.date = :yesterday
+          )
+        ) OR
+        (
+          time(:now) >= time('00:00:00', '-".(int)$timeLimit." minutes') AND
+          st.departure_time BETWEEN time('00:00:00') AND time(:now, '".(int)$timeLimit." minutes') AND
+          (
+            c.service_id IS NULL OR
+            (
               c.start_date <= :tomorrow AND
               c.end_date >= :tomorrow AND
               c.$dow2 = ".(int)Gtfs\Calendar\CalendarDay::AVAILABLE."
diff --git a/js/views/l9n.js b/js/views/l9n.js
index 1e0e7f4..ae61c75 100644
--- a/js/views/l9n.js
+++ b/js/views/l9n.js
@@ -1,7 +1,3 @@
-Number.prototype.mod = function(n) {
-  return ((this % n) + n) % n;
-}
-
 var scheduleController = {
   maxSchedules: 6,
   recommendedSchedulesPerLine: 2,
@@ -40,28 +36,9 @@
   removeDummy: function() {
     document.getElementById("trains").innerHTML = "";
   },
-  timeSinceMidnight: function() {
-    var now = new Date();
-    var then = new Date(
-      now.getFullYear(),
-      now.getMonth(),
-      now.getDate(),
-      0,0,0);
-    return Math.floor((now.getTime() - then.getTime())/1000);
-  },
   getTime: function() {
     return Math.floor((new Date()).getTime()/1000);
   },
-  timeSinceMidnightInverse: function(sec) {
-    var now = new Date();
-    var today = new Date(
-      now.getFullYear(),
-      now.getMonth(),
-      now.getDate(),
-      0,0,0);
-
-    return Math.floor(today.getTime()/1000) + sec;
-  },
   prettyTime: function(seconds, detailed = true) {
     if (detailed) return (Math.floor(seconds/60)).toString().padStart(2, '0')+":"+(seconds % 60).toString().padStart(2, '0');
     return Math.floor(seconds/60)+"min";
@@ -92,14 +69,13 @@
     trains.appendChild(train);
 
     data._element = time;
-    data._removeAtTime = scheduleController.timeSinceMidnightInverse(data.departureTime);
     scheduleController._times.push(data);
   },
   addTime: function(data) {
     if (data.departureTime > scheduleController._lastTime) {
       scheduleController._lastTime = data.departureTime;
       scheduleController._lastDestinations = [data.destination];
-    } else if (data.departureTime == scheduleController.lastTime) {
+    } else if (data.departureTime == scheduleController._lastTime) {
       var flag = false;
       scheduleController._lastDestinations.forEach(dest => {
         if (data.destination == dest) flag = true;
@@ -134,14 +110,13 @@
     setTimeout(scheduleController.fetchTimes, scheduleController.fetchInterval);
   },
   timer: function() {
-    var timeSinceMidnight = scheduleController.timeSinceMidnight();
-    var time = scheduleController.getTime();
+    var now = scheduleController.getTime();
 
     var removed = 0;
     for (var i = 0;; ++i) {
       if (scheduleController._times[i - removed] === undefined) break;
 
-      if (scheduleController._times[i - removed]._removeAtTime < time) {
+      if (scheduleController._times[i - removed].departureTime < now) {
         scheduleController.removeElement(scheduleController._times[i - removed]._element.parentNode);
         scheduleController._times.shift();
         ++removed;
@@ -156,7 +131,7 @@
 
     var i = 0;
     scheduleController._times.forEach(time => {
-      var diff = (time.departureTime - timeSinceMidnight).mod(24*60*60);
+      var diff = time.departureTime - now;
 
       time._element.textContent = scheduleController.prettyTime(diff, scheduleController.isDetailed(i));