[ Index ] |
PHP Cross Reference of Phabricator |
[Summary view] [Print] [Text view]
1 <?php 2 3 final class ManiphestReportController extends ManiphestController { 4 5 private $view; 6 7 public function willProcessRequest(array $data) { 8 $this->view = idx($data, 'view'); 9 } 10 11 public function processRequest() { 12 $request = $this->getRequest(); 13 $user = $request->getUser(); 14 15 if ($request->isFormPost()) { 16 $uri = $request->getRequestURI(); 17 18 $project = head($request->getArr('set_project')); 19 $project = nonempty($project, null); 20 $uri = $uri->alter('project', $project); 21 22 $window = $request->getStr('set_window'); 23 $uri = $uri->alter('window', $window); 24 25 return id(new AphrontRedirectResponse())->setURI($uri); 26 } 27 28 $nav = new AphrontSideNavFilterView(); 29 $nav->setBaseURI(new PhutilURI('/maniphest/report/')); 30 $nav->addLabel(pht('Open Tasks')); 31 $nav->addFilter('user', pht('By User')); 32 $nav->addFilter('project', pht('By Project')); 33 $nav->addLabel(pht('Burnup')); 34 $nav->addFilter('burn', pht('Burnup Rate')); 35 36 $this->view = $nav->selectFilter($this->view, 'user'); 37 38 require_celerity_resource('maniphest-report-css'); 39 40 switch ($this->view) { 41 case 'burn': 42 $core = $this->renderBurn(); 43 break; 44 case 'user': 45 case 'project': 46 $core = $this->renderOpenTasks(); 47 break; 48 default: 49 return new Aphront404Response(); 50 } 51 52 $nav->appendChild($core); 53 $nav->setCrumbs( 54 $this->buildApplicationCrumbs() 55 ->addTextCrumb(pht('Reports'))); 56 57 return $this->buildApplicationPage( 58 $nav, 59 array( 60 'title' => pht('Maniphest Reports'), 61 'device' => false, 62 )); 63 } 64 65 public function renderBurn() { 66 $request = $this->getRequest(); 67 $user = $request->getUser(); 68 69 $handle = null; 70 71 $project_phid = $request->getStr('project'); 72 if ($project_phid) { 73 $phids = array($project_phid); 74 $handles = $this->loadViewerHandles($phids); 75 $handle = $handles[$project_phid]; 76 } 77 78 $table = new ManiphestTransaction(); 79 $conn = $table->establishConnection('r'); 80 81 $joins = ''; 82 if ($project_phid) { 83 $joins = qsprintf( 84 $conn, 85 'JOIN %T t ON x.objectPHID = t.phid 86 JOIN %T p ON p.src = t.phid AND p.type = %d AND p.dst = %s', 87 id(new ManiphestTask())->getTableName(), 88 PhabricatorEdgeConfig::TABLE_NAME_EDGE, 89 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, 90 $project_phid); 91 } 92 93 $data = queryfx_all( 94 $conn, 95 'SELECT x.oldValue, x.newValue, x.dateCreated FROM %T x %Q 96 WHERE transactionType = %s 97 ORDER BY x.dateCreated ASC', 98 $table->getTableName(), 99 $joins, 100 ManiphestTransaction::TYPE_STATUS); 101 102 $stats = array(); 103 $day_buckets = array(); 104 105 $open_tasks = array(); 106 107 foreach ($data as $key => $row) { 108 109 // NOTE: Hack to avoid json_decode(). 110 $oldv = trim($row['oldValue'], '"'); 111 $newv = trim($row['newValue'], '"'); 112 113 if ($oldv == 'null') { 114 $old_is_open = false; 115 } else { 116 $old_is_open = ManiphestTaskStatus::isOpenStatus($oldv); 117 } 118 119 $new_is_open = ManiphestTaskStatus::isOpenStatus($newv); 120 121 $is_open = ($new_is_open && !$old_is_open); 122 $is_close = ($old_is_open && !$new_is_open); 123 124 $data[$key]['_is_open'] = $is_open; 125 $data[$key]['_is_close'] = $is_close; 126 127 if (!$is_open && !$is_close) { 128 // This is either some kind of bogus event, or a resolution change 129 // (e.g., resolved -> invalid). Just skip it. 130 continue; 131 } 132 133 $day_bucket = phabricator_format_local_time( 134 $row['dateCreated'], 135 $user, 136 'Yz'); 137 $day_buckets[$day_bucket] = $row['dateCreated']; 138 if (empty($stats[$day_bucket])) { 139 $stats[$day_bucket] = array( 140 'open' => 0, 141 'close' => 0, 142 ); 143 } 144 $stats[$day_bucket][$is_close ? 'close' : 'open']++; 145 } 146 147 $template = array( 148 'open' => 0, 149 'close' => 0, 150 ); 151 152 $rows = array(); 153 $rowc = array(); 154 $last_month = null; 155 $last_month_epoch = null; 156 $last_week = null; 157 $last_week_epoch = null; 158 $week = null; 159 $month = null; 160 161 $last = last_key($stats) - 1; 162 $period = $template; 163 164 foreach ($stats as $bucket => $info) { 165 $epoch = $day_buckets[$bucket]; 166 167 $week_bucket = phabricator_format_local_time( 168 $epoch, 169 $user, 170 'YW'); 171 if ($week_bucket != $last_week) { 172 if ($week) { 173 $rows[] = $this->formatBurnRow( 174 'Week of '.phabricator_date($last_week_epoch, $user), 175 $week); 176 $rowc[] = 'week'; 177 } 178 $week = $template; 179 $last_week = $week_bucket; 180 $last_week_epoch = $epoch; 181 } 182 183 $month_bucket = phabricator_format_local_time( 184 $epoch, 185 $user, 186 'Ym'); 187 if ($month_bucket != $last_month) { 188 if ($month) { 189 $rows[] = $this->formatBurnRow( 190 phabricator_format_local_time($last_month_epoch, $user, 'F, Y'), 191 $month); 192 $rowc[] = 'month'; 193 } 194 $month = $template; 195 $last_month = $month_bucket; 196 $last_month_epoch = $epoch; 197 } 198 199 $rows[] = $this->formatBurnRow(phabricator_date($epoch, $user), $info); 200 $rowc[] = null; 201 $week['open'] += $info['open']; 202 $week['close'] += $info['close']; 203 $month['open'] += $info['open']; 204 $month['close'] += $info['close']; 205 $period['open'] += $info['open']; 206 $period['close'] += $info['close']; 207 } 208 209 if ($week) { 210 $rows[] = $this->formatBurnRow( 211 pht('Week To Date'), 212 $week); 213 $rowc[] = 'week'; 214 } 215 216 if ($month) { 217 $rows[] = $this->formatBurnRow( 218 pht('Month To Date'), 219 $month); 220 $rowc[] = 'month'; 221 } 222 223 $rows[] = $this->formatBurnRow( 224 pht('All Time'), 225 $period); 226 $rowc[] = 'aggregate'; 227 228 $rows = array_reverse($rows); 229 $rowc = array_reverse($rowc); 230 231 $table = new AphrontTableView($rows); 232 $table->setRowClasses($rowc); 233 $table->setHeaders( 234 array( 235 pht('Period'), 236 pht('Opened'), 237 pht('Closed'), 238 pht('Change'), 239 )); 240 $table->setColumnClasses( 241 array( 242 'right wide', 243 'n', 244 'n', 245 'n', 246 )); 247 248 if ($handle) { 249 $inst = pht( 250 'NOTE: This table reflects tasks currently in '. 251 'the project. If a task was opened in the past but added to '. 252 'the project recently, it is counted on the day it was '. 253 'opened, not the day it was categorized. If a task was part '. 254 'of this project in the past but no longer is, it is not '. 255 'counted at all.'); 256 $header = pht('Task Burn Rate for Project %s', $handle->renderLink()); 257 $caption = phutil_tag('p', array(), $inst); 258 } else { 259 $header = pht('Task Burn Rate for All Tasks'); 260 $caption = null; 261 } 262 263 if ($caption) { 264 $caption = id(new AphrontErrorView()) 265 ->appendChild($caption) 266 ->setSeverity(AphrontErrorView::SEVERITY_NOTICE); 267 } 268 269 $panel = new PHUIObjectBoxView(); 270 $panel->setHeaderText($header); 271 if ($caption) { 272 $panel->setErrorView($caption); 273 } 274 $panel->appendChild($table); 275 276 $tokens = array(); 277 if ($handle) { 278 $tokens = array($handle); 279 } 280 281 $filter = $this->renderReportFilters($tokens, $has_window = false); 282 283 $id = celerity_generate_unique_node_id(); 284 $chart = phutil_tag( 285 'div', 286 array( 287 'id' => $id, 288 'style' => 'border: 1px solid #BFCFDA; '. 289 'background-color: #fff; '. 290 'margin: 8px 16px; '. 291 'height: 400px; ', 292 ), 293 ''); 294 295 list($burn_x, $burn_y) = $this->buildSeries($data); 296 297 require_celerity_resource('raphael-core'); 298 require_celerity_resource('raphael-g'); 299 require_celerity_resource('raphael-g-line'); 300 301 Javelin::initBehavior('line-chart', array( 302 'hardpoint' => $id, 303 'x' => array( 304 $burn_x, 305 ), 306 'y' => array( 307 $burn_y, 308 ), 309 'xformat' => 'epoch', 310 'yformat' => 'int', 311 )); 312 313 return array($filter, $chart, $panel); 314 } 315 316 private function renderReportFilters(array $tokens, $has_window) { 317 $request = $this->getRequest(); 318 $user = $request->getUser(); 319 320 $form = id(new AphrontFormView()) 321 ->setUser($user) 322 ->appendChild( 323 id(new AphrontFormTokenizerControl()) 324 ->setDatasource(new PhabricatorProjectDatasource()) 325 ->setLabel(pht('Project')) 326 ->setLimit(1) 327 ->setName('set_project') 328 ->setValue($tokens)); 329 330 if ($has_window) { 331 list($window_str, $ignored, $window_error) = $this->getWindow(); 332 $form 333 ->appendChild( 334 id(new AphrontFormTextControl()) 335 ->setLabel(pht('Recently Means')) 336 ->setName('set_window') 337 ->setCaption( 338 pht('Configure the cutoff for the "Recently Closed" column.')) 339 ->setValue($window_str) 340 ->setError($window_error)); 341 } 342 343 $form 344 ->appendChild( 345 id(new AphrontFormSubmitControl()) 346 ->setValue(pht('Filter By Project'))); 347 348 $filter = new AphrontListFilterView(); 349 $filter->appendChild($form); 350 351 return $filter; 352 } 353 354 private function buildSeries(array $data) { 355 $out = array(); 356 357 $counter = 0; 358 foreach ($data as $row) { 359 $t = (int)$row['dateCreated']; 360 if ($row['_is_close']) { 361 --$counter; 362 $out[$t] = $counter; 363 } else if ($row['_is_open']) { 364 ++$counter; 365 $out[$t] = $counter; 366 } 367 } 368 369 return array(array_keys($out), array_values($out)); 370 } 371 372 private function formatBurnRow($label, $info) { 373 $delta = $info['open'] - $info['close']; 374 $fmt = number_format($delta); 375 if ($delta > 0) { 376 $fmt = '+'.$fmt; 377 $fmt = phutil_tag('span', array('class' => 'red'), $fmt); 378 } else { 379 $fmt = phutil_tag('span', array('class' => 'green'), $fmt); 380 } 381 382 return array( 383 $label, 384 number_format($info['open']), 385 number_format($info['close']), 386 $fmt, 387 ); 388 } 389 390 public function renderOpenTasks() { 391 $request = $this->getRequest(); 392 $user = $request->getUser(); 393 394 395 $query = id(new ManiphestTaskQuery()) 396 ->setViewer($user) 397 ->withStatuses(ManiphestTaskStatus::getOpenStatusConstants()); 398 399 $project_phid = $request->getStr('project'); 400 $project_handle = null; 401 if ($project_phid) { 402 $phids = array($project_phid); 403 $handles = $this->loadViewerHandles($phids); 404 $project_handle = $handles[$project_phid]; 405 406 $query->withAnyProjects($phids); 407 } 408 409 $tasks = $query->execute(); 410 411 $recently_closed = $this->loadRecentlyClosedTasks(); 412 413 $date = phabricator_date(time(), $user); 414 415 switch ($this->view) { 416 case 'user': 417 $result = mgroup($tasks, 'getOwnerPHID'); 418 $leftover = idx($result, '', array()); 419 unset($result['']); 420 421 $result_closed = mgroup($recently_closed, 'getOwnerPHID'); 422 $leftover_closed = idx($result_closed, '', array()); 423 unset($result_closed['']); 424 425 $base_link = '/maniphest/?assigned='; 426 $leftover_name = phutil_tag('em', array(), pht('(Up For Grabs)')); 427 $col_header = pht('User'); 428 $header = pht('Open Tasks by User and Priority (%s)', $date); 429 break; 430 case 'project': 431 $result = array(); 432 $leftover = array(); 433 foreach ($tasks as $task) { 434 $phids = $task->getProjectPHIDs(); 435 if ($phids) { 436 foreach ($phids as $project_phid) { 437 $result[$project_phid][] = $task; 438 } 439 } else { 440 $leftover[] = $task; 441 } 442 } 443 444 $result_closed = array(); 445 $leftover_closed = array(); 446 foreach ($recently_closed as $task) { 447 $phids = $task->getProjectPHIDs(); 448 if ($phids) { 449 foreach ($phids as $project_phid) { 450 $result_closed[$project_phid][] = $task; 451 } 452 } else { 453 $leftover_closed[] = $task; 454 } 455 } 456 457 $base_link = '/maniphest/?allProjects='; 458 $leftover_name = phutil_tag('em', array(), pht('(No Project)')); 459 $col_header = pht('Project'); 460 $header = pht('Open Tasks by Project and Priority (%s)', $date); 461 break; 462 } 463 464 $phids = array_keys($result); 465 $handles = $this->loadViewerHandles($phids); 466 $handles = msort($handles, 'getName'); 467 468 $order = $request->getStr('order', 'name'); 469 list($order, $reverse) = AphrontTableView::parseSort($order); 470 471 require_celerity_resource('aphront-tooltip-css'); 472 Javelin::initBehavior('phabricator-tooltips', array()); 473 474 $rows = array(); 475 $pri_total = array(); 476 foreach (array_merge($handles, array(null)) as $handle) { 477 if ($handle) { 478 if (($project_handle) && 479 ($project_handle->getPHID() == $handle->getPHID())) { 480 // If filtering by, e.g., "bugs", don't show a "bugs" group. 481 continue; 482 } 483 484 $tasks = idx($result, $handle->getPHID(), array()); 485 $name = phutil_tag( 486 'a', 487 array( 488 'href' => $base_link.$handle->getPHID(), 489 ), 490 $handle->getName()); 491 $closed = idx($result_closed, $handle->getPHID(), array()); 492 } else { 493 $tasks = $leftover; 494 $name = $leftover_name; 495 $closed = $leftover_closed; 496 } 497 498 $taskv = $tasks; 499 $tasks = mgroup($tasks, 'getPriority'); 500 501 $row = array(); 502 $row[] = $name; 503 $total = 0; 504 foreach (ManiphestTaskPriority::getTaskPriorityMap() as $pri => $label) { 505 $n = count(idx($tasks, $pri, array())); 506 if ($n == 0) { 507 $row[] = '-'; 508 } else { 509 $row[] = number_format($n); 510 } 511 $total += $n; 512 } 513 $row[] = number_format($total); 514 515 list($link, $oldest_all) = $this->renderOldest($taskv); 516 $row[] = $link; 517 518 $normal_or_better = array(); 519 foreach ($taskv as $id => $task) { 520 // TODO: This is sort of a hard-code for the default "normal" status. 521 // When reports are more powerful, this should be made more general. 522 if ($task->getPriority() < 50) { 523 continue; 524 } 525 $normal_or_better[$id] = $task; 526 } 527 528 list($link, $oldest_pri) = $this->renderOldest($normal_or_better); 529 $row[] = $link; 530 531 if ($closed) { 532 $task_ids = implode(',', mpull($closed, 'getID')); 533 $row[] = phutil_tag( 534 'a', 535 array( 536 'href' => '/maniphest/?ids='.$task_ids, 537 'target' => '_blank', 538 ), 539 number_format(count($closed))); 540 } else { 541 $row[] = '-'; 542 } 543 544 switch ($order) { 545 case 'total': 546 $row['sort'] = $total; 547 break; 548 case 'oldest-all': 549 $row['sort'] = $oldest_all; 550 break; 551 case 'oldest-pri': 552 $row['sort'] = $oldest_pri; 553 break; 554 case 'closed': 555 $row['sort'] = count($closed); 556 break; 557 case 'name': 558 default: 559 $row['sort'] = $handle ? $handle->getName() : '~'; 560 break; 561 } 562 563 $rows[] = $row; 564 } 565 566 $rows = isort($rows, 'sort'); 567 foreach ($rows as $k => $row) { 568 unset($rows[$k]['sort']); 569 } 570 if ($reverse) { 571 $rows = array_reverse($rows); 572 } 573 574 $cname = array($col_header); 575 $cclass = array('pri right wide'); 576 $pri_map = ManiphestTaskPriority::getShortNameMap(); 577 foreach ($pri_map as $pri => $label) { 578 $cname[] = $label; 579 $cclass[] = 'n'; 580 } 581 $cname[] = 'Total'; 582 $cclass[] = 'n'; 583 $cname[] = javelin_tag( 584 'span', 585 array( 586 'sigil' => 'has-tooltip', 587 'meta' => array( 588 'tip' => pht('Oldest open task.'), 589 'size' => 200, 590 ), 591 ), 592 pht('Oldest (All)')); 593 $cclass[] = 'n'; 594 $cname[] = javelin_tag( 595 'span', 596 array( 597 'sigil' => 'has-tooltip', 598 'meta' => array( 599 'tip' => pht('Oldest open task, excluding those with Low or '. 600 'Wishlist priority.'), 601 'size' => 200, 602 ), 603 ), 604 pht('Oldest (Pri)')); 605 $cclass[] = 'n'; 606 607 list($ignored, $window_epoch) = $this->getWindow(); 608 $edate = phabricator_datetime($window_epoch, $user); 609 $cname[] = javelin_tag( 610 'span', 611 array( 612 'sigil' => 'has-tooltip', 613 'meta' => array( 614 'tip' => pht('Closed after %s', $edate), 615 'size' => 260, 616 ), 617 ), 618 pht('Recently Closed')); 619 $cclass[] = 'n'; 620 621 $table = new AphrontTableView($rows); 622 $table->setHeaders($cname); 623 $table->setColumnClasses($cclass); 624 $table->makeSortable( 625 $request->getRequestURI(), 626 'order', 627 $order, 628 $reverse, 629 array( 630 'name', 631 null, 632 null, 633 null, 634 null, 635 null, 636 null, 637 'total', 638 'oldest-all', 639 'oldest-pri', 640 'closed', 641 )); 642 643 $panel = new PHUIObjectBoxView(); 644 $panel->setHeaderText($header); 645 $panel->appendChild($table); 646 647 $tokens = array(); 648 if ($project_handle) { 649 $tokens = array($project_handle); 650 } 651 $filter = $this->renderReportFilters($tokens, $has_window = true); 652 653 return array($filter, $panel); 654 } 655 656 657 /** 658 * Load all the tasks that have been recently closed. 659 */ 660 private function loadRecentlyClosedTasks() { 661 list($ignored, $window_epoch) = $this->getWindow(); 662 663 $table = new ManiphestTask(); 664 $xtable = new ManiphestTransaction(); 665 $conn_r = $table->establishConnection('r'); 666 667 // TODO: Gross. This table is not meant to be queried like this. Build 668 // real stats tables. 669 670 $open_status_list = array(); 671 foreach (ManiphestTaskStatus::getOpenStatusConstants() as $constant) { 672 $open_status_list[] = json_encode((string)$constant); 673 } 674 675 $rows = queryfx_all( 676 $conn_r, 677 'SELECT t.id FROM %T t JOIN %T x ON x.objectPHID = t.phid 678 WHERE t.status NOT IN (%Ls) 679 AND x.oldValue IN (null, %Ls) 680 AND x.newValue NOT IN (%Ls) 681 AND t.dateModified >= %d 682 AND x.dateCreated >= %d', 683 $table->getTableName(), 684 $xtable->getTableName(), 685 ManiphestTaskStatus::getOpenStatusConstants(), 686 $open_status_list, 687 $open_status_list, 688 $window_epoch, 689 $window_epoch); 690 691 if (!$rows) { 692 return array(); 693 } 694 695 $ids = ipull($rows, 'id'); 696 697 return id(new ManiphestTaskQuery()) 698 ->setViewer($this->getRequest()->getUser()) 699 ->withIDs($ids) 700 ->execute(); 701 } 702 703 /** 704 * Parse the "Recently Means" filter into: 705 * 706 * - A string representation, like "12 AM 7 days ago" (default); 707 * - a locale-aware epoch representation; and 708 * - a possible error. 709 */ 710 private function getWindow() { 711 $request = $this->getRequest(); 712 $user = $request->getUser(); 713 714 $window_str = $this->getRequest()->getStr('window', '12 AM 7 days ago'); 715 716 $error = null; 717 $window_epoch = null; 718 719 // Do locale-aware parsing so that the user's timezone is assumed for 720 // time windows like "3 PM", rather than assuming the server timezone. 721 722 $window_epoch = PhabricatorTime::parseLocalTime($window_str, $user); 723 if (!$window_epoch) { 724 $error = 'Invalid'; 725 $window_epoch = time() - (60 * 60 * 24 * 7); 726 } 727 728 // If the time ends up in the future, convert it to the corresponding time 729 // and equal distance in the past. This is so users can type "6 days" (which 730 // means "6 days from now") and get the behavior of "6 days ago", rather 731 // than no results (because the window epoch is in the future). This might 732 // be a little confusing because it casues "tomorrow" to mean "yesterday" 733 // and "2022" (or whatever) to mean "ten years ago", but these inputs are 734 // nonsense anyway. 735 736 if ($window_epoch > time()) { 737 $window_epoch = time() - ($window_epoch - time()); 738 } 739 740 return array($window_str, $window_epoch, $error); 741 } 742 743 private function renderOldest(array $tasks) { 744 assert_instances_of($tasks, 'ManiphestTask'); 745 $oldest = null; 746 foreach ($tasks as $id => $task) { 747 if (($oldest === null) || 748 ($task->getDateCreated() < $tasks[$oldest]->getDateCreated())) { 749 $oldest = $id; 750 } 751 } 752 753 if ($oldest === null) { 754 return array('-', 0); 755 } 756 757 $oldest = $tasks[$oldest]; 758 759 $raw_age = (time() - $oldest->getDateCreated()); 760 $age = number_format($raw_age / (24 * 60 * 60)).' d'; 761 762 $link = javelin_tag( 763 'a', 764 array( 765 'href' => '/T'.$oldest->getID(), 766 'sigil' => 'has-tooltip', 767 'meta' => array( 768 'tip' => 'T'.$oldest->getID().': '.$oldest->getTitle(), 769 ), 770 'target' => '_blank', 771 ), 772 $age); 773 774 return array($link, $raw_age); 775 } 776 777 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Sun Nov 30 09:20:46 2014 | Cross-referenced by PHPXref 0.7.1 |