[ Index ]

PHP Cross Reference of Phabricator

title

Body

[close]

/src/applications/maniphest/controller/ -> ManiphestReportController.php (source)

   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  }


Generated: Sun Nov 30 09:20:46 2014 Cross-referenced by PHPXref 0.7.1