@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Make Phrequent time accounting aware of the stack

Summary:
Ref T3569. Fixes T3567. When figuring out how much time has been spent on an object, subtract "preemptive" events which interrupted the object.

Also, make the UI look vaguely sane:

{F72773}

Test Plan: Added a bunch of unit tests, mucked around in the UI.

Reviewers: btrahan

Reviewed By: btrahan

CC: hach-que, skyronic, aran

Maniphest Tasks: T3567, T3569

Differential Revision: https://secure.phabricator.com/D7349

+431 -86
+4
src/__phutil_library_map__.php
··· 1958 1958 'PhrequentDAO' => 'applications/phrequent/storage/PhrequentDAO.php', 1959 1959 'PhrequentListController' => 'applications/phrequent/controller/PhrequentListController.php', 1960 1960 'PhrequentSearchEngine' => 'applications/phrequent/query/PhrequentSearchEngine.php', 1961 + 'PhrequentTimeBlock' => 'applications/phrequent/storage/PhrequentTimeBlock.php', 1962 + 'PhrequentTimeBlockTestCase' => 'applications/phrequent/storage/__tests__/PhrequentTimeBlockTestCase.php', 1961 1963 'PhrequentTrackController' => 'applications/phrequent/controller/PhrequentTrackController.php', 1962 1964 'PhrequentTrackableInterface' => 'applications/phrequent/interface/PhrequentTrackableInterface.php', 1963 1965 'PhrequentUIEventListener' => 'applications/phrequent/event/PhrequentUIEventListener.php', ··· 4230 4232 1 => 'PhabricatorApplicationSearchResultsControllerInterface', 4231 4233 ), 4232 4234 'PhrequentSearchEngine' => 'PhabricatorApplicationSearchEngine', 4235 + 'PhrequentTimeBlock' => 'Phobject', 4236 + 'PhrequentTimeBlockTestCase' => 'PhabricatorTestCase', 4233 4237 'PhrequentTrackController' => 'PhrequentController', 4234 4238 'PhrequentUIEventListener' => 'PhutilEventListener', 4235 4239 'PhrequentUserTime' =>
+52 -39
src/applications/phrequent/event/PhrequentUIEventListener.php
··· 61 61 $event->setValue('actions', $actions); 62 62 } 63 63 64 - private function handlePropertyEvent($event) { 65 - $user = $event->getUser(); 66 - $object = $event->getValue('object'); 64 + private function handlePropertyEvent($ui_event) { 65 + $user = $ui_event->getUser(); 66 + $object = $ui_event->getValue('object'); 67 67 68 68 if (!$object || !$object->getPHID()) { 69 69 // No object, or the object has no PHID yet.. ··· 75 75 return; 76 76 } 77 77 78 - $depth = false; 78 + $events = id(new PhrequentUserTimeQuery()) 79 + ->setViewer($user) 80 + ->withObjectPHIDs(array($object->getPHID())) 81 + ->needPreemptingEvents(true) 82 + ->execute(); 83 + $event_groups = mgroup($events, 'getUserPHID'); 79 84 80 - $stack = PhrequentUserTimeQuery::loadUserStack($user); 81 - if ($stack) { 82 - $stack = array_values($stack); 83 - for ($ii = 0; $ii < count($stack); $ii++) { 84 - if ($stack[$ii]->getObjectPHID() == $object->getPHID()) { 85 - $depth = ($ii + 1); 86 - break; 87 - } 88 - } 85 + if (!$events) { 86 + return; 89 87 } 90 88 91 - $time_spent = PhrequentUserTimeQuery::getTotalTimeSpentOnObject( 92 - $object->getPHID()); 89 + $handles = id(new PhabricatorHandleQuery()) 90 + ->setViewer($user) 91 + ->withPHIDs(array_keys($event_groups)) 92 + ->execute(); 93 + 94 + $status_view = new PHUIStatusListView(); 95 + 96 + foreach ($event_groups as $user_phid => $event_group) { 97 + $item = new PHUIStatusItemView(); 98 + $item->setTarget($handles[$user_phid]->renderLink()); 93 99 94 - if (!$depth && !$time_spent) { 95 - return; 96 - } 100 + $state = 'stopped'; 101 + foreach ($event_group as $event) { 102 + if ($event->getDateEnded() === null) { 103 + if ($event->isPreempted()) { 104 + $state = 'suspended'; 105 + } else { 106 + $state = 'active'; 107 + break; 108 + } 109 + } 110 + } 97 111 98 - require_celerity_resource('phrequent-css'); 112 + switch ($state) { 113 + case 'active': 114 + $item->setIcon('time-green', pht('Working Now')); 115 + break; 116 + case 'suspended': 117 + $item->setIcon('time-yellow', pht('Interrupted')); 118 + break; 119 + case 'stopped': 120 + $item->setIcon('time-orange', pht('Not Working Now')); 121 + break; 122 + } 99 123 100 - $property = array(); 101 - if ($depth == 1) { 102 - $property[] = phutil_tag( 103 - 'div', 104 - array( 105 - 'class' => 'phrequent-tracking-property phrequent-active', 106 - ), 107 - pht('Currently Tracking')); 108 - } else if ($depth > 1) { 109 - $property[] = phutil_tag( 110 - 'div', 111 - array( 112 - 'class' => 'phrequent-tracking-property phrequent-on-stack', 113 - ), 114 - pht('On Stack')); 115 - } 124 + $block = new PhrequentTimeBlock($event_group); 125 + $item->setNote( 126 + phabricator_format_relative_time( 127 + $block->getTimeSpentOnObject( 128 + $object->getPHID(), 129 + time()))); 116 130 117 - if ($time_spent) { 118 - $property[] = phabricator_format_relative_time_detailed($time_spent); 131 + $status_view->addItem($item); 119 132 } 120 133 121 - $view = $event->getValue('view'); 122 - $view->addProperty(pht('Time Spent'), $property); 134 + $view = $ui_event->getValue('view'); 135 + $view->addProperty(pht('Time Spent'), $status_view); 123 136 } 124 137 125 138 }
+62 -40
src/applications/phrequent/query/PhrequentUserTimeQuery.php
··· 21 21 private $order = self::ORDER_ID_ASC; 22 22 private $ended = self::ENDED_ALL; 23 23 24 + private $needPreemptingEvents; 25 + 24 26 public function withUserPHIDs($user_phids) { 25 27 $this->userPHIDs = $user_phids; 26 28 return $this; ··· 38 40 39 41 public function setOrder($order) { 40 42 $this->order = $order; 43 + return $this; 44 + } 45 + 46 + public function needPreemptingEvents($need_events) { 47 + $this->needPreemptingEvents = $need_events; 41 48 return $this; 42 49 } 43 50 ··· 150 157 return $usertime->loadAllFromArray($data); 151 158 } 152 159 160 + protected function didFilterPage(array $page) { 161 + if ($this->needPreemptingEvents) { 162 + $usertime = new PhrequentUserTime(); 163 + $conn_r = $usertime->establishConnection('r'); 164 + 165 + $preempt = array(); 166 + foreach ($page as $event) { 167 + $preempt[] = qsprintf( 168 + $conn_r, 169 + '(userPHID = %s AND 170 + (dateStarted BETWEEN %d AND %d) AND 171 + (dateEnded IS NULL OR dateEnded > %d))', 172 + $event->getUserPHID(), 173 + $event->getDateStarted(), 174 + nonempty($event->getDateEnded(), PhabricatorTime::getNow()), 175 + $event->getDateStarted()); 176 + } 177 + 178 + $preempting_events = queryfx_all( 179 + $conn_r, 180 + 'SELECT * FROM %T WHERE %Q ORDER BY dateStarted ASC, id ASC', 181 + $usertime->getTableName(), 182 + implode(' OR ', $preempt)); 183 + $preempting_events = $usertime->loadAllFromArray($preempting_events); 184 + 185 + $preempting_events = mgroup($preempting_events, 'getUserPHID'); 186 + 187 + foreach ($page as $event) { 188 + $e_start = $event->getDateStarted(); 189 + $e_end = $event->getDateEnded(); 190 + 191 + $select = array(); 192 + $user_events = idx($preempting_events, $event->getUserPHID(), array()); 193 + foreach ($user_events as $u_event) { 194 + if ($u_event->getID() == $event->getID()) { 195 + // Don't allow an event to preempt itself. 196 + continue; 197 + } 198 + 199 + $u_start = $u_event->getDateStarted(); 200 + $u_end = $u_event->getDateEnded(); 201 + 202 + if (($u_start >= $e_start) && ($u_end <= $e_end) && 203 + ($u_end === null || $u_end > $e_start)) { 204 + $select[] = $u_event; 205 + } 206 + } 207 + 208 + $event->attachPreemptingEvents($select); 209 + } 210 + } 211 + 212 + return $page; 213 + } 214 + 153 215 /* -( Helper Functions ) --------------------------------------------------- */ 154 216 155 217 public static function getEndedSearchOptions() { ··· 202 264 $user->getPHID(), 203 265 $phid); 204 266 return $count['N'] > 0; 205 - } 206 - 207 - public static function loadUserStack(PhabricatorUser $user) { 208 - if (!$user->isLoggedIn()) { 209 - return array(); 210 - } 211 - 212 - return id(new PhrequentUserTime())->loadAllWhere( 213 - 'userPHID = %s AND dateEnded IS NULL 214 - ORDER BY dateStarted DESC, id DESC', 215 - $user->getPHID()); 216 - } 217 - 218 - public static function getTotalTimeSpentOnObject($phid) { 219 - $usertime_dao = new PhrequentUserTime(); 220 - $conn = $usertime_dao->establishConnection('r'); 221 - 222 - // First calculate all the time spent where the 223 - // usertime blocks have ended. 224 - $sum_ended = queryfx_one( 225 - $conn, 226 - 'SELECT SUM(usertime.dateEnded - usertime.dateStarted) N '. 227 - 'FROM %T usertime '. 228 - 'WHERE usertime.objectPHID = %s '. 229 - 'AND usertime.dateEnded IS NOT NULL', 230 - $usertime_dao->getTableName(), 231 - $phid); 232 - 233 - // Now calculate the time spent where the usertime 234 - // blocks have not yet ended. 235 - $sum_not_ended = queryfx_one( 236 - $conn, 237 - 'SELECT SUM(UNIX_TIMESTAMP() - usertime.dateStarted) N '. 238 - 'FROM %T usertime '. 239 - 'WHERE usertime.objectPHID = %s '. 240 - 'AND usertime.dateEnded IS NULL', 241 - $usertime_dao->getTableName(), 242 - $phid); 243 - 244 - return $sum_ended['N'] + $sum_not_ended['N']; 245 267 } 246 268 247 269 public static function getUserTimeSpentOnObject(
+155
src/applications/phrequent/storage/PhrequentTimeBlock.php
··· 1 + <?php 2 + 3 + final class PhrequentTimeBlock extends Phobject { 4 + 5 + private $events; 6 + 7 + public function __construct(array $events) { 8 + assert_instances_of($events, 'PhrequentUserTime'); 9 + $this->events = $events; 10 + } 11 + 12 + public function getTimeSpentOnObject($phid, $now) { 13 + $ranges = idx($this->getObjectTimeRanges($now), $phid, array()); 14 + 15 + $sum = 0; 16 + foreach ($ranges as $range) { 17 + $sum += ($range[1] - $range[0]); 18 + } 19 + 20 + return $sum; 21 + } 22 + 23 + public function getObjectTimeRanges($now) { 24 + $ranges = array(); 25 + 26 + $object_ranges = array(); 27 + foreach ($this->events as $event) { 28 + 29 + // First, convert each event's preempting stack into a linear timeline 30 + // of events. 31 + 32 + $timeline = array(); 33 + $timeline[] = array( 34 + 'at' => $event->getDateStarted(), 35 + 'type' => 'start', 36 + ); 37 + $timeline[] = array( 38 + 'at' => nonempty($event->getDateEnded(), $now), 39 + 'type' => 'end', 40 + ); 41 + 42 + $base_phid = $event->getObjectPHID(); 43 + 44 + $preempts = $event->getPreemptingEvents(); 45 + 46 + foreach ($preempts as $preempt) { 47 + $same_object = ($preempt->getObjectPHID() == $base_phid); 48 + $timeline[] = array( 49 + 'at' => $preempt->getDateStarted(), 50 + 'type' => $same_object ? 'start' : 'push', 51 + ); 52 + $timeline[] = array( 53 + 'at' => nonempty($preempt->getDateEnded(), $now), 54 + 'type' => $same_object ? 'end' : 'pop', 55 + ); 56 + } 57 + 58 + // Now, figure out how much time was actually spent working on the 59 + // object. 60 + 61 + $timeline = isort($timeline, 'at'); 62 + 63 + $stack = array(); 64 + $depth = null; 65 + 66 + $ranges = array(); 67 + foreach ($timeline as $timeline_event) { 68 + switch ($timeline_event['type']) { 69 + case 'start': 70 + $stack[] = $depth; 71 + $depth = 0; 72 + $range_start = $timeline_event['at']; 73 + break; 74 + case 'end': 75 + if ($depth == 0) { 76 + $ranges[] = array($range_start, $timeline_event['at']); 77 + } 78 + $depth = array_pop($stack); 79 + break; 80 + case 'push': 81 + if ($depth == 0) { 82 + $ranges[] = array($range_start, $timeline_event['at']); 83 + } 84 + $depth++; 85 + break; 86 + case 'pop': 87 + $depth--; 88 + if ($depth == 0) { 89 + $range_start = $timeline_event['at']; 90 + } 91 + break; 92 + } 93 + } 94 + 95 + $object_ranges[$base_phid][] = $ranges; 96 + } 97 + 98 + // Finally, collapse all the ranges so we don't double-count time. 99 + 100 + foreach ($object_ranges as $phid => $ranges) { 101 + $object_ranges[$phid] = self::mergeTimeRanges(array_mergev($ranges)); 102 + } 103 + 104 + return $object_ranges; 105 + } 106 + 107 + 108 + /** 109 + * Merge a list of time ranges (pairs of `<start, end>` epochs) so that no 110 + * elements overlap. For example, the ranges: 111 + * 112 + * array( 113 + * array(50, 150), 114 + * array(100, 175), 115 + * ); 116 + * 117 + * ...are merged to: 118 + * 119 + * array( 120 + * array(50, 175), 121 + * ); 122 + * 123 + * This is used to avoid double-counting time on objects which had timers 124 + * started multiple times. 125 + * 126 + * @param list<pair<int, int>> List of possibly overlapping time ranges. 127 + * @return list<pair<int, int>> Nonoverlapping time ranges. 128 + */ 129 + public static function mergeTimeRanges(array $ranges) { 130 + $ranges = isort($ranges, 0); 131 + 132 + $result = array(); 133 + 134 + $current = null; 135 + foreach ($ranges as $key => $range) { 136 + if ($current === null) { 137 + $current = $range; 138 + continue; 139 + } 140 + 141 + if ($range[0] <= $current[1]) { 142 + $current[1] = max($range[1], $current[1]); 143 + continue; 144 + } 145 + 146 + $result[] = $current; 147 + $current = $range; 148 + } 149 + 150 + $result[] = $current; 151 + 152 + return $result; 153 + } 154 + 155 + }
+30 -7
src/applications/phrequent/storage/PhrequentUserTime.php
··· 1 1 <?php 2 2 3 - /** 4 - * @group phrequent 5 - */ 6 3 final class PhrequentUserTime extends PhrequentDAO 7 4 implements PhabricatorPolicyInterface { 8 5 ··· 11 8 protected $note; 12 9 protected $dateStarted; 13 10 protected $dateEnded; 11 + 12 + private $preemptingEvents = self::ATTACHABLE; 14 13 15 14 public function getCapabilities() { 16 15 return array( ··· 23 22 24 23 switch ($capability) { 25 24 case PhabricatorPolicyCapability::CAN_VIEW: 26 - $policy = PhabricatorPolicies::POLICY_USER; 27 - break; 25 + // Since it's impossible to perform any meaningful computations with 26 + // time if a user can't view some of it, visibility on tracked time is 27 + // unrestricted. If we eventually lock it down, it should be per-user. 28 + // (This doesn't mean that users can see tracked objects.) 29 + return PhabricatorPolicies::getMostOpenPolicy(); 28 30 } 29 31 30 32 return $policy; ··· 36 38 37 39 38 40 public function describeAutomaticCapability($capability) { 39 - return pht( 40 - 'The user who tracked time can always view it.'); 41 + return null; 42 + } 43 + 44 + public function attachPreemptingEvents(array $events) { 45 + $this->preemptingEvents = $events; 46 + return $this; 47 + } 48 + 49 + public function getPreemptingEvents() { 50 + return $this->assertAttached($this->preemptingEvents); 51 + } 52 + 53 + public function isPreempted() { 54 + if ($this->getDateEnded() !== null) { 55 + return false; 56 + } 57 + foreach ($this->getPreemptingEvents() as $event) { 58 + if ($event->getDateEnded() === null && 59 + $event->getObjectPHID() != $this->getObjectPHID()) { 60 + return true; 61 + } 62 + } 63 + return false; 41 64 } 42 65 43 66 }
+128
src/applications/phrequent/storage/__tests__/PhrequentTimeBlockTestCase.php
··· 1 + <?php 2 + 3 + final class PhrequentTimeBlockTestCase extends PhabricatorTestCase { 4 + 5 + public function testMergeTimeRanges() { 6 + 7 + // Overlapping ranges. 8 + $input = array( 9 + array(50, 150), 10 + array(100, 175), 11 + ); 12 + $expect = array( 13 + array(50, 175), 14 + ); 15 + 16 + $this->assertEqual($expect, PhrequentTimeBlock::mergeTimeRanges($input)); 17 + 18 + 19 + // Identical ranges. 20 + $input = array( 21 + array(1, 1), 22 + array(1, 1), 23 + array(1, 1), 24 + ); 25 + $expect = array( 26 + array(1, 1), 27 + ); 28 + 29 + $this->assertEqual($expect, PhrequentTimeBlock::mergeTimeRanges($input)); 30 + 31 + 32 + // Range which is a strict subset of another range. 33 + $input = array( 34 + array(2, 7), 35 + array(1, 10), 36 + ); 37 + $expect = array( 38 + array(1, 10), 39 + ); 40 + 41 + $this->assertEqual($expect, PhrequentTimeBlock::mergeTimeRanges($input)); 42 + 43 + 44 + // These are discontinuous and should not be merged. 45 + $input = array( 46 + array(5, 6), 47 + array(7, 8), 48 + ); 49 + $expect = array( 50 + array(5, 6), 51 + array(7, 8), 52 + ); 53 + 54 + $this->assertEqual($expect, PhrequentTimeBlock::mergeTimeRanges($input)); 55 + 56 + 57 + // These overlap only on an edge, but should merge. 58 + $input = array( 59 + array(5, 6), 60 + array(6, 7), 61 + ); 62 + $expect = array( 63 + array(5, 7), 64 + ); 65 + 66 + $this->assertEqual($expect, PhrequentTimeBlock::mergeTimeRanges($input)); 67 + } 68 + 69 + public function testPreemptingEvents() { 70 + 71 + // Roughly, this is: got into work, started T1, had a meeting from 10-11, 72 + // left the office around 2 to meet a client at a coffee shop, worked on 73 + // T1 again for about 40 minutes, had the meeting from 3-3:30, finished up 74 + // on T1, headed back to the office, got another hour of work in, and 75 + // headed home. 76 + 77 + $event = $this->newEvent('T1', 900, 1700); 78 + 79 + $event->attachPreemptingEvents( 80 + array( 81 + $this->newEvent('meeting', 1000, 1100), 82 + $this->newEvent('offsite', 1400, 1600), 83 + $this->newEvent('T1', 1420, 1580), 84 + $this->newEvent('offsite meeting', 1500, 1550), 85 + )); 86 + 87 + $block = new PhrequentTimeBlock(array($event)); 88 + 89 + $ranges = $block->getObjectTimeRanges(1800); 90 + $this->assertEqual( 91 + array( 92 + 'T1' => array( 93 + array(900, 1000), // Before morning meeting. 94 + array(1100, 1400), // After morning meeting. 95 + array(1420, 1500), // Coffee, before client meeting. 96 + array(1550, 1580), // Coffee, after client meeting. 97 + array(1600, 1700), // After returning from off site. 98 + ), 99 + ), 100 + $ranges); 101 + 102 + 103 + $event = $this->newEvent('T2', 100, 300); 104 + $event->attachPreemptingEvents( 105 + array( 106 + $this->newEvent('meeting', 200, null), 107 + )); 108 + 109 + $block = new PhrequentTimeBlock(array($event)); 110 + 111 + $ranges = $block->getObjectTimeRanges(1000); 112 + $this->assertEqual( 113 + array( 114 + 'T2' => array( 115 + array(100, 200), 116 + ), 117 + ), 118 + $ranges); 119 + } 120 + 121 + private function newEvent($object_phid, $start_time, $end_time) { 122 + return id(new PhrequentUserTime()) 123 + ->setObjectPHID($object_phid) 124 + ->setDateStarted($start_time) 125 + ->setDateEnded($end_time); 126 + } 127 + 128 + }