@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<?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}