@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
at upstream/main 329 lines 9.4 kB view raw
1<?php 2 3final class PhrequentTimeBlock extends Phobject { 4 5 private $events; 6 7 /** 8 * @param array<PhrequentUserTime> $events 9 */ 10 public function __construct(array $events) { 11 assert_instances_of($events, PhrequentUserTime::class); 12 $this->events = $events; 13 } 14 15 public function getTimeSpentOnObject($phid, $now) { 16 $slices = idx($this->getObjectTimeRanges(), $phid); 17 18 if (!$slices) { 19 return null; 20 } 21 22 return $slices->getDuration($now); 23 } 24 25 /** 26 * @return array<PhrequentTimeSlices> 27 */ 28 public function getObjectTimeRanges() { 29 $ranges = array(); 30 31 $range_start = time(); 32 foreach ($this->events as $event) { 33 $range_start = min($range_start, $event->getDateStarted()); 34 } 35 36 $object_ranges = array(); 37 $object_ongoing = array(); 38 foreach ($this->events as $event) { 39 40 // First, convert each event's preempting stack into a linear timeline 41 // of events. 42 43 $timeline = array(); 44 $timeline[] = array( 45 'event' => $event, 46 'at' => (int)$event->getDateStarted(), 47 'type' => 'start', 48 ); 49 $timeline[] = array( 50 'event' => $event, 51 'at' => (int)nonempty($event->getDateEnded(), PHP_INT_MAX), 52 'type' => 'end', 53 ); 54 55 $base_phid = $event->getObjectPHID(); 56 if (!$event->getDateEnded()) { 57 $object_ongoing[$base_phid] = true; 58 } 59 60 $preempts = $event->getPreemptingEvents(); 61 foreach ($preempts as $preempt) { 62 $same_object = ($preempt->getObjectPHID() == $base_phid); 63 $timeline[] = array( 64 'event' => $preempt, 65 'at' => (int)$preempt->getDateStarted(), 66 'type' => $same_object ? 'start' : 'push', 67 ); 68 $timeline[] = array( 69 'event' => $preempt, 70 'at' => (int)nonempty($preempt->getDateEnded(), PHP_INT_MAX), 71 'type' => $same_object ? 'end' : 'pop', 72 ); 73 } 74 75 // Now, figure out how much time was actually spent working on the 76 // object. 77 78 usort($timeline, array(self::class, 'sortTimeline')); 79 80 $stack = array(); 81 $depth = null; 82 83 // NOTE: "Strata" track the separate layers between each event tracking 84 // the object we care about. Events might look like this: 85 // 86 // |xxxxxxxxxxxxxxxxx| 87 // |yyyyyyy| 88 // |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| 89 // 9AM 5PM 90 // 91 // ...where we care about event "x". When "y" is popped, that shouldn't 92 // pop the top stack -- we need to pop the stack a level down. Each 93 // event tracking "x" creates a new stratum, and we keep track of where 94 // timeline events are among the strata in order to keep stack depths 95 // straight. 96 97 $stratum = null; 98 $strata = array(); 99 100 $ranges = array(); 101 foreach ($timeline as $timeline_event) { 102 $id = $timeline_event['event']->getID(); 103 $type = $timeline_event['type']; 104 105 switch ($type) { 106 case 'start': 107 $stack[] = $depth; 108 $depth = 0; 109 $stratum = count($stack); 110 $strata[$id] = $stratum; 111 $range_start = $timeline_event['at']; 112 break; 113 case 'end': 114 if ($strata[$id] == $stratum) { 115 if ($depth == 0) { 116 $ranges[] = array($range_start, $timeline_event['at']); 117 $depth = array_pop($stack); 118 } else { 119 // Here, we've prematurely ended the current stratum. Merge all 120 // the higher strata into it. This looks like this: 121 // 122 // V 123 // V 124 // |zzzzzzzz| 125 // |xxxxx| 126 // |yyyyyyyyyyyyy| 127 // |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| 128 129 $depth = array_pop($stack) + $depth; 130 } 131 } else { 132 // Here, we've prematurely ended a deeper stratum. Merge higher 133 // strata. This looks like this: 134 // 135 // V 136 // V 137 // |aaaaaaa| 138 // |xxxxxxxxxxxxxxxxxxx| 139 // |zzzzzzzzzzzzz| 140 // |xxxxxxx| 141 // |yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy| 142 // |xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| 143 144 $extra = $stack[$strata[$id]]; 145 unset($stack[$strata[$id] - 1]); 146 $stack = array_values($stack); 147 $stack[$strata[$id] - 1] += $extra; 148 } 149 150 // Regardless of how we got here, we need to merge down any higher 151 // strata. 152 $target = $strata[$id]; 153 foreach ($strata as $strata_id => $id_stratum) { 154 if ($id_stratum >= $target) { 155 $strata[$strata_id]--; 156 } 157 } 158 $stratum = count($stack); 159 160 unset($strata[$id]); 161 break; 162 case 'push': 163 $strata[$id] = $stratum; 164 if ($depth == 0) { 165 $ranges[] = array($range_start, $timeline_event['at']); 166 } 167 $depth++; 168 break; 169 case 'pop': 170 if ($strata[$id] == $stratum) { 171 $depth--; 172 if ($depth == 0) { 173 $range_start = $timeline_event['at']; 174 } 175 } else { 176 $stack[$strata[$id]]--; 177 } 178 unset($strata[$id]); 179 break; 180 } 181 } 182 183 // Filter out ranges with an indefinite start time. These occur when 184 // popping the stack when there are multiple ongoing events. 185 foreach ($ranges as $key => $range) { 186 if ($range[0] == PHP_INT_MAX) { 187 unset($ranges[$key]); 188 } 189 } 190 191 $object_ranges[$base_phid][] = $ranges; 192 } 193 194 // Collapse all the ranges so we don't double-count time. 195 foreach ($object_ranges as $phid => $ranges) { 196 $object_ranges[$phid] = self::mergeTimeRanges(array_mergev($ranges)); 197 } 198 199 foreach ($object_ranges as $phid => $ranges) { 200 foreach ($ranges as $key => $range) { 201 if ($range[1] == PHP_INT_MAX) { 202 $ranges[$key][1] = null; 203 } 204 } 205 206 $object_ranges[$phid] = new PhrequentTimeSlices( 207 $phid, 208 isset($object_ongoing[$phid]), 209 $ranges); 210 } 211 212 // Reorder the ranges to be more stack-like, so the first item is the 213 // top of the stack. 214 $object_ranges = array_reverse($object_ranges, $preserve_keys = true); 215 216 return $object_ranges; 217 } 218 219 /** 220 * Returns the current list of work. 221 */ 222 public function getCurrentWorkStack($now, $include_inactive = false) { 223 $ranges = $this->getObjectTimeRanges(); 224 225 $results = array(); 226 $active = null; 227 foreach ($ranges as $phid => $slices) { 228 if (!$include_inactive) { 229 if (!$slices->getIsOngoing()) { 230 continue; 231 } 232 } 233 234 $results[] = array( 235 'phid' => $phid, 236 'time' => $slices->getDuration($now), 237 'ongoing' => $slices->getIsOngoing(), 238 ); 239 } 240 241 return $results; 242 } 243 244 245 /** 246 * Merge a list of time ranges (pairs of `<start, end>` epochs) so that no 247 * elements overlap. For example, the ranges: 248 * 249 * array( 250 * array(50, 150), 251 * array(100, 175), 252 * ); 253 * 254 * ...are merged to: 255 * 256 * array( 257 * array(50, 175), 258 * ); 259 * 260 * This is used to avoid double-counting time on objects which had timers 261 * started multiple times. 262 * 263 * @param list<array<int, int>> $ranges List of possibly overlapping time 264 * range pairs. 265 * @return list<array<int, int>> Nonoverlapping time range pairs. 266 */ 267 public static function mergeTimeRanges(array $ranges) { 268 $ranges = isort($ranges, '0'); 269 270 $result = array(); 271 272 $current = null; 273 foreach ($ranges as $key => $range) { 274 if ($current === null) { 275 $current = $range; 276 continue; 277 } 278 279 if ($range[0] <= $current[1]) { 280 $current[1] = max($range[1], $current[1]); 281 continue; 282 } 283 284 $result[] = $current; 285 $current = $range; 286 } 287 288 $result[] = $current; 289 290 return $result; 291 } 292 293 294 /** 295 * Sort events in timeline order. Notably, for events which occur on the same 296 * second, we want to process end events after start events. 297 */ 298 public static function sortTimeline(array $u, array $v) { 299 // If these events occur at different times, ordering is obvious. 300 if ($u['at'] != $v['at']) { 301 return ($u['at'] < $v['at']) ? -1 : 1; 302 } 303 304 $u_end = ($u['type'] == 'end' || $u['type'] == 'pop'); 305 $v_end = ($v['type'] == 'end' || $v['type'] == 'pop'); 306 307 $u_id = $u['event']->getID(); 308 $v_id = $v['event']->getID(); 309 310 if ($u_end == $v_end) { 311 // These are both start events or both end events. Sort them by ID. 312 if (!$u_end) { 313 return ($u_id < $v_id) ? -1 : 1; 314 } else { 315 return ($u_id < $v_id) ? 1 : -1; 316 } 317 } else { 318 // Sort them (start, end) if they're the same event, and (end, start) 319 // otherwise. 320 if ($u_id == $v_id) { 321 return $v_end ? -1 : 1; 322 } else { 323 return $v_end ? 1 : -1; 324 } 325 } 326 327 } 328 329}