Index: src/applications/phrequent/storage/PhrequentTimeBlock.php =================================================================== --- src/applications/phrequent/storage/PhrequentTimeBlock.php +++ src/applications/phrequent/storage/PhrequentTimeBlock.php @@ -31,10 +31,12 @@ $timeline = array(); $timeline[] = array( + 'event' => $event, 'at' => $event->getDateStarted(), 'type' => 'start', ); $timeline[] = array( + 'event' => $event, 'at' => nonempty($event->getDateEnded(), $now), 'type' => 'end', ); @@ -46,10 +48,12 @@ foreach ($preempts as $preempt) { $same_object = ($preempt->getObjectPHID() == $base_phid); $timeline[] = array( + 'event' => $preempt, 'at' => $preempt->getDateStarted(), 'type' => $same_object ? 'start' : 'push', ); $timeline[] = array( + 'event' => $preempt, 'at' => nonempty($preempt->getDateEnded(), $now), 'type' => $same_object ? 'end' : 'pop', ); @@ -58,36 +62,108 @@ // Now, figure out how much time was actually spent working on the // object. - $timeline = isort($timeline, 'at'); + usort($timeline, array(__CLASS__, 'sortTimeline')); $stack = array(); $depth = null; + // NOTE: "Strata" track the separate layers between each event tracking + // the object we care about. Events might look like this: + // + // |xxxxxxxxxxxxxxxxx| + // |yyyyyyy| + // |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| + // 9AM 5PM + // + // ...where we care about event "x". When "y" is popped, that shouldn't + // pop the top stack -- we need to pop the stack a level down. Each + // event tracking "x" creates a new stratum, and we keep track of where + // timeline events are among the strata in order to keep stack depths + // straight. + + $stratum = null; + $strata = array(); + + $ranges = array(); foreach ($timeline as $timeline_event) { - switch ($timeline_event['type']) { + $id = $timeline_event['event']->getID(); + $type = $timeline_event['type']; + + switch ($type) { case 'start': $stack[] = $depth; $depth = 0; + $stratum = count($stack); + $strata[$id] = $stratum; $range_start = $timeline_event['at']; break; case 'end': - if ($depth == 0) { - $ranges[] = array($range_start, $timeline_event['at']); + if ($strata[$id] == $stratum) { + if ($depth == 0) { + $ranges[] = array($range_start, $timeline_event['at']); + $depth = array_pop($stack); + } else { + // Here, we've prematurely ended the current stratum. Merge all + // the higher strata into it. This looks like this: + // + // V + // V + // |zzzzzzzz| + // |xxxxx| + // |yyyyyyyyyyyyy| + // |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| + + $depth = array_pop($stack) + $depth; + } + } else { + // Here, we've prematurely ended a deeper stratum. Merge higher + // stata. This looks like this: + // + // V + // V + // |aaaaaaa| + // |xxxxxxxxxxxxxxxxxxx| + // |zzzzzzzzzzzzz| + // |xxxxxxx| + // |yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy| + // |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| + + $extra = $stack[$strata[$id]]; + unset($stack[$strata[$id] - 1]); + $stack = array_values($stack); + $stack[$strata[$id] - 1] += $extra; + } + + // Regardless of how we got here, we need to merge down any higher + // strata. + $target = $strata[$id]; + foreach ($strata as $strata_id => $id_stratum) { + if ($id_stratum >= $target) { + $strata[$strata_id]--; + } } - $depth = array_pop($stack); + $stratum = count($stack); + + unset($strata[$id]); break; case 'push': + $strata[$id] = $stratum; if ($depth == 0) { $ranges[] = array($range_start, $timeline_event['at']); } $depth++; break; case 'pop': - $depth--; - if ($depth == 0) { - $range_start = $timeline_event['at']; + if ($strata[$id] == $stratum) { + $depth--; + if ($depth == 0) { + $range_start = $timeline_event['at']; + } + } else { + $stack[$strata[$id]]--; } + unset($strata[$id]); break; } } @@ -152,4 +228,41 @@ return $result; } + + /** + * Sort events in timeline order. Notably, for events which occur on the same + * second, we want to process end events after start events. + */ + public static function sortTimeline(array $u, array $v) { + // If these events occur at different times, ordering is obvious. + if ($u['at'] != $v['at']) { + return ($u['at'] < $v['at']) ? -1 : 1; + } + + $u_end = ($u['type'] == 'end' || $u['type'] == 'pop'); + $v_end = ($v['type'] == 'end' || $v['type'] == 'pop'); + + $u_id = $u['event']->getID(); + $v_id = $v['event']->getID(); + + if ($u_end == $v_end) { + // These are both start events or both end events. Sort them by ID. + if (!$u_end) { + return ($u_id < $v_id) ? -1 : 1; + } else { + return ($u_id < $v_id) ? 1 : -1; + } + } else { + // Sort them (start, end) if they're the same event, and (end, start) + // otherwise. + if ($u_id == $v_id) { + return $v_end ? -1 : 1; + } else { + return $v_end ? 1 : -1; + } + } + + return 0; + } + } Index: src/applications/phrequent/storage/__tests__/PhrequentTimeBlockTestCase.php =================================================================== --- src/applications/phrequent/storage/__tests__/PhrequentTimeBlockTestCase.php +++ src/applications/phrequent/storage/__tests__/PhrequentTimeBlockTestCase.php @@ -99,7 +99,6 @@ ), $ranges); - $event = $this->newEvent('T2', 100, 300); $event->attachPreemptingEvents( array( @@ -118,8 +117,107 @@ $ranges); } + public function testTimelineSort() { + $e1 = $this->newEvent('X1', 1, 1)->setID(1); + + $in = array( + array( + 'event' => $e1, + 'at' => 1, + 'type' => 'start', + ), + array( + 'event' => $e1, + 'at' => 1, + 'type' => 'end', + ), + ); + + usort($in, array('PhrequentTimeBlock', 'sortTimeline')); + + $this->assertEqual( + array( + 'start', + 'end', + ), + ipull($in, 'type')); + } + + public function testInstantaneousEvent() { + + $event = $this->newEvent('T1', 8, 8); + $event->attachPreemptingEvents(array()); + + $block = new PhrequentTimeBlock(array($event)); + + $ranges = $block->getObjectTimeRanges(1800); + $this->assertEqual( + array( + 'T1' => array( + array(8, 8), + ), + ), + $ranges); + } + + public function testPopAcrossStrata() { + + $event = $this->newEvent('T1', 1, 1000); + $event->attachPreemptingEvents( + array( + $this->newEvent('T2', 100, 300), + $this->newEvent('T1', 200, 400), + $this->newEvent('T3', 250, 275), + )); + + $block = new PhrequentTimeBlock(array($event)); + + $ranges = $block->getObjectTimeRanges(1000); + + $this->assertEqual( + array( + 'T1' => array( + array(1, 100), + array(200, 250), + array(275, 1000), + ), + ), + $ranges); + } + + public function testEndDeeperStratum() { + $event = $this->newEvent('T1', 1, 1000); + $event->attachPreemptingEvents( + array( + $this->newEvent('T2', 100, 900), + $this->newEvent('T1', 200, 400), + $this->newEvent('T3', 300, 800), + $this->newEvent('T1', 350, 600), + $this->newEvent('T4', 380, 390), + )); + + $block = new PhrequentTimeBlock(array($event)); + + $ranges = $block->getObjectTimeRanges(1000); + + $this->assertEqual( + array( + 'T1' => array( + array(1, 100), + array(200, 300), + array(350, 380), + array(390, 600), + array(900, 1000), + ), + ), + $ranges); + } + private function newEvent($object_phid, $start_time, $end_time) { + static $id = 0; + return id(new PhrequentUserTime()) + ->setID(++$id) ->setObjectPHID($object_phid) ->setDateStarted($start_time) ->setDateEnded($end_time);