Page MenuHomePhabricator

D7349.diff

diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -1958,6 +1958,8 @@
'PhrequentDAO' => 'applications/phrequent/storage/PhrequentDAO.php',
'PhrequentListController' => 'applications/phrequent/controller/PhrequentListController.php',
'PhrequentSearchEngine' => 'applications/phrequent/query/PhrequentSearchEngine.php',
+ 'PhrequentTimeBlock' => 'applications/phrequent/storage/PhrequentTimeBlock.php',
+ 'PhrequentTimeBlockTestCase' => 'applications/phrequent/storage/__tests__/PhrequentTimeBlockTestCase.php',
'PhrequentTrackController' => 'applications/phrequent/controller/PhrequentTrackController.php',
'PhrequentTrackableInterface' => 'applications/phrequent/interface/PhrequentTrackableInterface.php',
'PhrequentUIEventListener' => 'applications/phrequent/event/PhrequentUIEventListener.php',
@@ -4230,6 +4232,8 @@
1 => 'PhabricatorApplicationSearchResultsControllerInterface',
),
'PhrequentSearchEngine' => 'PhabricatorApplicationSearchEngine',
+ 'PhrequentTimeBlock' => 'Phobject',
+ 'PhrequentTimeBlockTestCase' => 'PhabricatorTestCase',
'PhrequentTrackController' => 'PhrequentController',
'PhrequentUIEventListener' => 'PhutilEventListener',
'PhrequentUserTime' =>
diff --git a/src/applications/phrequent/event/PhrequentUIEventListener.php b/src/applications/phrequent/event/PhrequentUIEventListener.php
--- a/src/applications/phrequent/event/PhrequentUIEventListener.php
+++ b/src/applications/phrequent/event/PhrequentUIEventListener.php
@@ -61,9 +61,9 @@
$event->setValue('actions', $actions);
}
- private function handlePropertyEvent($event) {
- $user = $event->getUser();
- $object = $event->getValue('object');
+ private function handlePropertyEvent($ui_event) {
+ $user = $ui_event->getUser();
+ $object = $ui_event->getValue('object');
if (!$object || !$object->getPHID()) {
// No object, or the object has no PHID yet..
@@ -75,51 +75,64 @@
return;
}
- $depth = false;
+ $events = id(new PhrequentUserTimeQuery())
+ ->setViewer($user)
+ ->withObjectPHIDs(array($object->getPHID()))
+ ->needPreemptingEvents(true)
+ ->execute();
+ $event_groups = mgroup($events, 'getUserPHID');
- $stack = PhrequentUserTimeQuery::loadUserStack($user);
- if ($stack) {
- $stack = array_values($stack);
- for ($ii = 0; $ii < count($stack); $ii++) {
- if ($stack[$ii]->getObjectPHID() == $object->getPHID()) {
- $depth = ($ii + 1);
- break;
- }
- }
+ if (!$events) {
+ return;
}
- $time_spent = PhrequentUserTimeQuery::getTotalTimeSpentOnObject(
- $object->getPHID());
+ $handles = id(new PhabricatorHandleQuery())
+ ->setViewer($user)
+ ->withPHIDs(array_keys($event_groups))
+ ->execute();
+
+ $status_view = new PHUIStatusListView();
+
+ foreach ($event_groups as $user_phid => $event_group) {
+ $item = new PHUIStatusItemView();
+ $item->setTarget($handles[$user_phid]->renderLink());
+
+ $state = 'stopped';
+ foreach ($event_group as $event) {
+ if ($event->getDateEnded() === null) {
+ if ($event->isPreempted()) {
+ $state = 'suspended';
+ } else {
+ $state = 'active';
+ break;
+ }
+ }
+ }
- if (!$depth && !$time_spent) {
- return;
- }
+ switch ($state) {
+ case 'active':
+ $item->setIcon('time-green', pht('Working Now'));
+ break;
+ case 'suspended':
+ $item->setIcon('time-yellow', pht('Interrupted'));
+ break;
+ case 'stopped':
+ $item->setIcon('time-orange', pht('Not Working Now'));
+ break;
+ }
- require_celerity_resource('phrequent-css');
-
- $property = array();
- if ($depth == 1) {
- $property[] = phutil_tag(
- 'div',
- array(
- 'class' => 'phrequent-tracking-property phrequent-active',
- ),
- pht('Currently Tracking'));
- } else if ($depth > 1) {
- $property[] = phutil_tag(
- 'div',
- array(
- 'class' => 'phrequent-tracking-property phrequent-on-stack',
- ),
- pht('On Stack'));
- }
+ $block = new PhrequentTimeBlock($event_group);
+ $item->setNote(
+ phabricator_format_relative_time(
+ $block->getTimeSpentOnObject(
+ $object->getPHID(),
+ time())));
- if ($time_spent) {
- $property[] = phabricator_format_relative_time_detailed($time_spent);
+ $status_view->addItem($item);
}
- $view = $event->getValue('view');
- $view->addProperty(pht('Time Spent'), $property);
+ $view = $ui_event->getValue('view');
+ $view->addProperty(pht('Time Spent'), $status_view);
}
}
diff --git a/src/applications/phrequent/query/PhrequentUserTimeQuery.php b/src/applications/phrequent/query/PhrequentUserTimeQuery.php
--- a/src/applications/phrequent/query/PhrequentUserTimeQuery.php
+++ b/src/applications/phrequent/query/PhrequentUserTimeQuery.php
@@ -21,6 +21,8 @@
private $order = self::ORDER_ID_ASC;
private $ended = self::ENDED_ALL;
+ private $needPreemptingEvents;
+
public function withUserPHIDs($user_phids) {
$this->userPHIDs = $user_phids;
return $this;
@@ -41,6 +43,11 @@
return $this;
}
+ public function needPreemptingEvents($need_events) {
+ $this->needPreemptingEvents = $need_events;
+ return $this;
+ }
+
private function buildWhereClause(AphrontDatabaseConnection $conn) {
$where = array();
@@ -150,6 +157,61 @@
return $usertime->loadAllFromArray($data);
}
+ protected function didFilterPage(array $page) {
+ if ($this->needPreemptingEvents) {
+ $usertime = new PhrequentUserTime();
+ $conn_r = $usertime->establishConnection('r');
+
+ $preempt = array();
+ foreach ($page as $event) {
+ $preempt[] = qsprintf(
+ $conn_r,
+ '(userPHID = %s AND
+ (dateStarted BETWEEN %d AND %d) AND
+ (dateEnded IS NULL OR dateEnded > %d))',
+ $event->getUserPHID(),
+ $event->getDateStarted(),
+ nonempty($event->getDateEnded(), PhabricatorTime::getNow()),
+ $event->getDateStarted());
+ }
+
+ $preempting_events = queryfx_all(
+ $conn_r,
+ 'SELECT * FROM %T WHERE %Q ORDER BY dateStarted ASC, id ASC',
+ $usertime->getTableName(),
+ implode(' OR ', $preempt));
+ $preempting_events = $usertime->loadAllFromArray($preempting_events);
+
+ $preempting_events = mgroup($preempting_events, 'getUserPHID');
+
+ foreach ($page as $event) {
+ $e_start = $event->getDateStarted();
+ $e_end = $event->getDateEnded();
+
+ $select = array();
+ $user_events = idx($preempting_events, $event->getUserPHID(), array());
+ foreach ($user_events as $u_event) {
+ if ($u_event->getID() == $event->getID()) {
+ // Don't allow an event to preempt itself.
+ continue;
+ }
+
+ $u_start = $u_event->getDateStarted();
+ $u_end = $u_event->getDateEnded();
+
+ if (($u_start >= $e_start) && ($u_end <= $e_end) &&
+ ($u_end === null || $u_end > $e_start)) {
+ $select[] = $u_event;
+ }
+ }
+
+ $event->attachPreemptingEvents($select);
+ }
+ }
+
+ return $page;
+ }
+
/* -( Helper Functions ) --------------------------------------------------- */
public static function getEndedSearchOptions() {
@@ -204,46 +266,6 @@
return $count['N'] > 0;
}
- public static function loadUserStack(PhabricatorUser $user) {
- if (!$user->isLoggedIn()) {
- return array();
- }
-
- return id(new PhrequentUserTime())->loadAllWhere(
- 'userPHID = %s AND dateEnded IS NULL
- ORDER BY dateStarted DESC, id DESC',
- $user->getPHID());
- }
-
- public static function getTotalTimeSpentOnObject($phid) {
- $usertime_dao = new PhrequentUserTime();
- $conn = $usertime_dao->establishConnection('r');
-
- // First calculate all the time spent where the
- // usertime blocks have ended.
- $sum_ended = queryfx_one(
- $conn,
- 'SELECT SUM(usertime.dateEnded - usertime.dateStarted) N '.
- 'FROM %T usertime '.
- 'WHERE usertime.objectPHID = %s '.
- 'AND usertime.dateEnded IS NOT NULL',
- $usertime_dao->getTableName(),
- $phid);
-
- // Now calculate the time spent where the usertime
- // blocks have not yet ended.
- $sum_not_ended = queryfx_one(
- $conn,
- 'SELECT SUM(UNIX_TIMESTAMP() - usertime.dateStarted) N '.
- 'FROM %T usertime '.
- 'WHERE usertime.objectPHID = %s '.
- 'AND usertime.dateEnded IS NULL',
- $usertime_dao->getTableName(),
- $phid);
-
- return $sum_ended['N'] + $sum_not_ended['N'];
- }
-
public static function getUserTimeSpentOnObject(
PhabricatorUser $user,
$phid) {
diff --git a/src/applications/phrequent/storage/PhrequentTimeBlock.php b/src/applications/phrequent/storage/PhrequentTimeBlock.php
new file mode 100644
--- /dev/null
+++ b/src/applications/phrequent/storage/PhrequentTimeBlock.php
@@ -0,0 +1,155 @@
+<?php
+
+final class PhrequentTimeBlock extends Phobject {
+
+ private $events;
+
+ public function __construct(array $events) {
+ assert_instances_of($events, 'PhrequentUserTime');
+ $this->events = $events;
+ }
+
+ public function getTimeSpentOnObject($phid, $now) {
+ $ranges = idx($this->getObjectTimeRanges($now), $phid, array());
+
+ $sum = 0;
+ foreach ($ranges as $range) {
+ $sum += ($range[1] - $range[0]);
+ }
+
+ return $sum;
+ }
+
+ public function getObjectTimeRanges($now) {
+ $ranges = array();
+
+ $object_ranges = array();
+ foreach ($this->events as $event) {
+
+ // First, convert each event's preempting stack into a linear timeline
+ // of events.
+
+ $timeline = array();
+ $timeline[] = array(
+ 'at' => $event->getDateStarted(),
+ 'type' => 'start',
+ );
+ $timeline[] = array(
+ 'at' => nonempty($event->getDateEnded(), $now),
+ 'type' => 'end',
+ );
+
+ $base_phid = $event->getObjectPHID();
+
+ $preempts = $event->getPreemptingEvents();
+
+ foreach ($preempts as $preempt) {
+ $same_object = ($preempt->getObjectPHID() == $base_phid);
+ $timeline[] = array(
+ 'at' => $preempt->getDateStarted(),
+ 'type' => $same_object ? 'start' : 'push',
+ );
+ $timeline[] = array(
+ 'at' => nonempty($preempt->getDateEnded(), $now),
+ 'type' => $same_object ? 'end' : 'pop',
+ );
+ }
+
+ // Now, figure out how much time was actually spent working on the
+ // object.
+
+ $timeline = isort($timeline, 'at');
+
+ $stack = array();
+ $depth = null;
+
+ $ranges = array();
+ foreach ($timeline as $timeline_event) {
+ switch ($timeline_event['type']) {
+ case 'start':
+ $stack[] = $depth;
+ $depth = 0;
+ $range_start = $timeline_event['at'];
+ break;
+ case 'end':
+ if ($depth == 0) {
+ $ranges[] = array($range_start, $timeline_event['at']);
+ }
+ $depth = array_pop($stack);
+ break;
+ case 'push':
+ if ($depth == 0) {
+ $ranges[] = array($range_start, $timeline_event['at']);
+ }
+ $depth++;
+ break;
+ case 'pop':
+ $depth--;
+ if ($depth == 0) {
+ $range_start = $timeline_event['at'];
+ }
+ break;
+ }
+ }
+
+ $object_ranges[$base_phid][] = $ranges;
+ }
+
+ // Finally, collapse all the ranges so we don't double-count time.
+
+ foreach ($object_ranges as $phid => $ranges) {
+ $object_ranges[$phid] = self::mergeTimeRanges(array_mergev($ranges));
+ }
+
+ return $object_ranges;
+ }
+
+
+ /**
+ * Merge a list of time ranges (pairs of `<start, end>` epochs) so that no
+ * elements overlap. For example, the ranges:
+ *
+ * array(
+ * array(50, 150),
+ * array(100, 175),
+ * );
+ *
+ * ...are merged to:
+ *
+ * array(
+ * array(50, 175),
+ * );
+ *
+ * This is used to avoid double-counting time on objects which had timers
+ * started multiple times.
+ *
+ * @param list<pair<int, int>> List of possibly overlapping time ranges.
+ * @return list<pair<int, int>> Nonoverlapping time ranges.
+ */
+ public static function mergeTimeRanges(array $ranges) {
+ $ranges = isort($ranges, 0);
+
+ $result = array();
+
+ $current = null;
+ foreach ($ranges as $key => $range) {
+ if ($current === null) {
+ $current = $range;
+ continue;
+ }
+
+ if ($range[0] <= $current[1]) {
+ $current[1] = max($range[1], $current[1]);
+ continue;
+ }
+
+ $result[] = $current;
+ $current = $range;
+ }
+
+ $result[] = $current;
+
+ return $result;
+ }
+
+}
diff --git a/src/applications/phrequent/storage/PhrequentUserTime.php b/src/applications/phrequent/storage/PhrequentUserTime.php
--- a/src/applications/phrequent/storage/PhrequentUserTime.php
+++ b/src/applications/phrequent/storage/PhrequentUserTime.php
@@ -1,8 +1,5 @@
<?php
-/**
- * @group phrequent
- */
final class PhrequentUserTime extends PhrequentDAO
implements PhabricatorPolicyInterface {
@@ -12,6 +9,8 @@
protected $dateStarted;
protected $dateEnded;
+ private $preemptingEvents = self::ATTACHABLE;
+
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
@@ -23,8 +22,11 @@
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
- $policy = PhabricatorPolicies::POLICY_USER;
- break;
+ // Since it's impossible to perform any meaningful computations with
+ // time if a user can't view some of it, visibility on tracked time is
+ // unrestricted. If we eventually lock it down, it should be per-user.
+ // (This doesn't mean that users can see tracked objects.)
+ return PhabricatorPolicies::getMostOpenPolicy();
}
return $policy;
@@ -36,8 +38,29 @@
public function describeAutomaticCapability($capability) {
- return pht(
- 'The user who tracked time can always view it.');
+ return null;
+ }
+
+ public function attachPreemptingEvents(array $events) {
+ $this->preemptingEvents = $events;
+ return $this;
+ }
+
+ public function getPreemptingEvents() {
+ return $this->assertAttached($this->preemptingEvents);
+ }
+
+ public function isPreempted() {
+ if ($this->getDateEnded() !== null) {
+ return false;
+ }
+ foreach ($this->getPreemptingEvents() as $event) {
+ if ($event->getDateEnded() === null &&
+ $event->getObjectPHID() != $this->getObjectPHID()) {
+ return true;
+ }
+ }
+ return false;
}
}
diff --git a/src/applications/phrequent/storage/__tests__/PhrequentTimeBlockTestCase.php b/src/applications/phrequent/storage/__tests__/PhrequentTimeBlockTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/applications/phrequent/storage/__tests__/PhrequentTimeBlockTestCase.php
@@ -0,0 +1,128 @@
+<?php
+
+final class PhrequentTimeBlockTestCase extends PhabricatorTestCase {
+
+ public function testMergeTimeRanges() {
+
+ // Overlapping ranges.
+ $input = array(
+ array(50, 150),
+ array(100, 175),
+ );
+ $expect = array(
+ array(50, 175),
+ );
+
+ $this->assertEqual($expect, PhrequentTimeBlock::mergeTimeRanges($input));
+
+
+ // Identical ranges.
+ $input = array(
+ array(1, 1),
+ array(1, 1),
+ array(1, 1),
+ );
+ $expect = array(
+ array(1, 1),
+ );
+
+ $this->assertEqual($expect, PhrequentTimeBlock::mergeTimeRanges($input));
+
+
+ // Range which is a strict subset of another range.
+ $input = array(
+ array(2, 7),
+ array(1, 10),
+ );
+ $expect = array(
+ array(1, 10),
+ );
+
+ $this->assertEqual($expect, PhrequentTimeBlock::mergeTimeRanges($input));
+
+
+ // These are discontinuous and should not be merged.
+ $input = array(
+ array(5, 6),
+ array(7, 8),
+ );
+ $expect = array(
+ array(5, 6),
+ array(7, 8),
+ );
+
+ $this->assertEqual($expect, PhrequentTimeBlock::mergeTimeRanges($input));
+
+
+ // These overlap only on an edge, but should merge.
+ $input = array(
+ array(5, 6),
+ array(6, 7),
+ );
+ $expect = array(
+ array(5, 7),
+ );
+
+ $this->assertEqual($expect, PhrequentTimeBlock::mergeTimeRanges($input));
+ }
+
+ public function testPreemptingEvents() {
+
+ // Roughly, this is: got into work, started T1, had a meeting from 10-11,
+ // left the office around 2 to meet a client at a coffee shop, worked on
+ // T1 again for about 40 minutes, had the meeting from 3-3:30, finished up
+ // on T1, headed back to the office, got another hour of work in, and
+ // headed home.
+
+ $event = $this->newEvent('T1', 900, 1700);
+
+ $event->attachPreemptingEvents(
+ array(
+ $this->newEvent('meeting', 1000, 1100),
+ $this->newEvent('offsite', 1400, 1600),
+ $this->newEvent('T1', 1420, 1580),
+ $this->newEvent('offsite meeting', 1500, 1550),
+ ));
+
+ $block = new PhrequentTimeBlock(array($event));
+
+ $ranges = $block->getObjectTimeRanges(1800);
+ $this->assertEqual(
+ array(
+ 'T1' => array(
+ array(900, 1000), // Before morning meeting.
+ array(1100, 1400), // After morning meeting.
+ array(1420, 1500), // Coffee, before client meeting.
+ array(1550, 1580), // Coffee, after client meeting.
+ array(1600, 1700), // After returning from off site.
+ ),
+ ),
+ $ranges);
+
+
+ $event = $this->newEvent('T2', 100, 300);
+ $event->attachPreemptingEvents(
+ array(
+ $this->newEvent('meeting', 200, null),
+ ));
+
+ $block = new PhrequentTimeBlock(array($event));
+
+ $ranges = $block->getObjectTimeRanges(1000);
+ $this->assertEqual(
+ array(
+ 'T2' => array(
+ array(100, 200),
+ ),
+ ),
+ $ranges);
+ }
+
+ private function newEvent($object_phid, $start_time, $end_time) {
+ return id(new PhrequentUserTime())
+ ->setObjectPHID($object_phid)
+ ->setDateStarted($start_time)
+ ->setDateEnded($end_time);
+ }
+
+}

File Metadata

Mime Type
text/x-diff
Storage Engine
amazon-s3
Storage Format
Raw Data
Storage Handle
phabricator/5y/oz/3szwqvptnmk7zwfc
Default Alt Text
D7349.diff (18 KB)

Event Timeline