From 663a1c20e6ba0fbf65afcb43f0f48d34f21dcb53 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 5 Jul 2015 21:22:31 -0400 Subject: Add new analytic page: Average time spent into each column --- app/Template/analytic/avg_time_columns.php | 29 +++++++++++++++++++++++++++++ app/Template/analytic/sidebar.php | 3 +++ 2 files changed, 32 insertions(+) create mode 100644 app/Template/analytic/avg_time_columns.php (limited to 'app/Template/analytic') diff --git a/app/Template/analytic/avg_time_columns.php b/app/Template/analytic/avg_time_columns.php new file mode 100644 index 00000000..e74e7950 --- /dev/null +++ b/app/Template/analytic/avg_time_columns.php @@ -0,0 +1,29 @@ + + + +

+ +
+ +
+ + + + + + + + + + + + +
e($column['title']) ?>dt->duration($column['average']) ?>
+ +

+ +

+
+ diff --git a/app/Template/analytic/sidebar.php b/app/Template/analytic/sidebar.php index 2d1a7c96..1ffce710 100644 --- a/app/Template/analytic/sidebar.php +++ b/app/Template/analytic/sidebar.php @@ -13,5 +13,8 @@
  • url->link(t('Burndown chart'), 'analytic', 'burndown', array('project_id' => $project['id'])) ?>
  • +
  • + url->link(t('Average time into each column'), 'analytic', 'averageTimeByColumn', array('project_id' => $project['id'])) ?> +
  • \ No newline at end of file -- cgit v1.2.3 From 08259d4f206438095308749b8cc2abbe629137da Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Mon, 6 Jul 2015 21:34:57 -0400 Subject: Add lead and cycle time for projects --- app/Console/ProjectDailyColumnStatsExport.php | 34 ++++ app/Console/ProjectDailyStatsCalculation.php | 28 ++++ app/Console/ProjectDailySummaryCalculation.php | 27 ---- app/Console/ProjectDailySummaryExport.php | 34 ---- app/Controller/Analytic.php | 40 ++++- app/Controller/Export.php | 2 +- app/Core/Base.php | 3 +- app/Model/ProjectAnalytic.php | 37 +++++ app/Model/ProjectDailyColumnStats.php | 193 +++++++++++++++++++++++ app/Model/ProjectDailyStats.php | 72 +++++++++ app/Model/ProjectDailySummary.php | 193 ----------------------- app/Schema/Mysql.php | 21 ++- app/Schema/Postgres.php | 20 ++- app/Schema/Sqlite.php | 20 ++- app/ServiceProvider/ClassProvider.php | 3 +- app/Subscriber/ProjectDailySummarySubscriber.php | 3 +- app/Template/analytic/lead_cycle_time.php | 42 +++++ app/Template/analytic/sidebar.php | 3 + assets/js/app.js | 21 +-- assets/js/src/analytic.js | 48 ++++++ composer.json | 2 +- composer.lock | 13 +- docs/analytics.markdown | 4 +- docs/cli.markdown | 55 ++++--- kanboard | 4 +- scripts/create-sample-burndown.php | 4 +- scripts/create-sample-cfd.php | 4 +- scripts/create-sample-lead-time.php | 70 ++++++++ tests/units/ProjectDailyColumnStatsTest.php | 90 +++++++++++ tests/units/ProjectDailySummaryTest.php | 90 ----------- 30 files changed, 773 insertions(+), 407 deletions(-) create mode 100644 app/Console/ProjectDailyColumnStatsExport.php create mode 100644 app/Console/ProjectDailyStatsCalculation.php delete mode 100644 app/Console/ProjectDailySummaryCalculation.php delete mode 100644 app/Console/ProjectDailySummaryExport.php create mode 100644 app/Model/ProjectDailyColumnStats.php create mode 100644 app/Model/ProjectDailyStats.php delete mode 100644 app/Model/ProjectDailySummary.php create mode 100644 app/Template/analytic/lead_cycle_time.php create mode 100755 scripts/create-sample-lead-time.php create mode 100644 tests/units/ProjectDailyColumnStatsTest.php delete mode 100644 tests/units/ProjectDailySummaryTest.php (limited to 'app/Template/analytic') diff --git a/app/Console/ProjectDailyColumnStatsExport.php b/app/Console/ProjectDailyColumnStatsExport.php new file mode 100644 index 00000000..b9830662 --- /dev/null +++ b/app/Console/ProjectDailyColumnStatsExport.php @@ -0,0 +1,34 @@ +setName('export:daily-project-column-stats') + ->setDescription('Daily project column stats CSV export (number of tasks per column and per day)') + ->addArgument('project_id', InputArgument::REQUIRED, 'Project id') + ->addArgument('start_date', InputArgument::REQUIRED, 'Start date (YYYY-MM-DD)') + ->addArgument('end_date', InputArgument::REQUIRED, 'End date (YYYY-MM-DD)'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $data = $this->projectDailyColumnStats->getAggregatedMetrics( + $input->getArgument('project_id'), + $input->getArgument('start_date'), + $input->getArgument('end_date') + ); + + if (is_array($data)) { + Tool::csv($data); + } + } +} diff --git a/app/Console/ProjectDailyStatsCalculation.php b/app/Console/ProjectDailyStatsCalculation.php new file mode 100644 index 00000000..4b77c556 --- /dev/null +++ b/app/Console/ProjectDailyStatsCalculation.php @@ -0,0 +1,28 @@ +setName('projects:daily-stats') + ->setDescription('Calculate daily statistics for all projects'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $projects = $this->project->getAllByStatus(Project::ACTIVE); + + foreach ($projects as $project) { + $output->writeln('Run calculation for '.$project['name']); + $this->projectDailyColumnStats->updateTotals($project['id'], date('Y-m-d')); + $this->projectDailyStats->updateTotals($project['id'], date('Y-m-d')); + } + } +} diff --git a/app/Console/ProjectDailySummaryCalculation.php b/app/Console/ProjectDailySummaryCalculation.php deleted file mode 100644 index b2ada1b6..00000000 --- a/app/Console/ProjectDailySummaryCalculation.php +++ /dev/null @@ -1,27 +0,0 @@ -setName('projects:daily-summary') - ->setDescription('Calculate daily summary data for all projects'); - } - - protected function execute(InputInterface $input, OutputInterface $output) - { - $projects = $this->project->getAllByStatus(Project::ACTIVE); - - foreach ($projects as $project) { - $output->writeln('Run calculation for '.$project['name']); - $this->projectDailySummary->updateTotals($project['id'], date('Y-m-d')); - } - } -} diff --git a/app/Console/ProjectDailySummaryExport.php b/app/Console/ProjectDailySummaryExport.php deleted file mode 100644 index 07841d52..00000000 --- a/app/Console/ProjectDailySummaryExport.php +++ /dev/null @@ -1,34 +0,0 @@ -setName('export:daily-project-summary') - ->setDescription('Daily project summary CSV export (number of tasks per column and per day)') - ->addArgument('project_id', InputArgument::REQUIRED, 'Project id') - ->addArgument('start_date', InputArgument::REQUIRED, 'Start date (YYYY-MM-DD)') - ->addArgument('end_date', InputArgument::REQUIRED, 'End date (YYYY-MM-DD)'); - } - - protected function execute(InputInterface $input, OutputInterface $output) - { - $data = $this->projectDailySummary->getAggregatedMetrics( - $input->getArgument('project_id'), - $input->getArgument('start_date'), - $input->getArgument('end_date') - ); - - if (is_array($data)) { - Tool::csv($data); - } - } -} diff --git a/app/Controller/Analytic.php b/app/Controller/Analytic.php index 010fda09..ca2146ed 100644 --- a/app/Controller/Analytic.php +++ b/app/Controller/Analytic.php @@ -26,6 +26,40 @@ class Analytic extends Base return $this->template->layout('analytic/layout', $params); } + /** + * Show average Lead and Cycle time + * + * @access public + */ + public function leadAndCycleTime() + { + $project = $this->getProject(); + $values = $this->request->getValues(); + + $this->projectDailyStats->updateTotals($project['id'], date('Y-m-d')); + + $from = $this->request->getStringParam('from', date('Y-m-d', strtotime('-1week'))); + $to = $this->request->getStringParam('to', date('Y-m-d')); + + if (! empty($values)) { + $from = $values['from']; + $to = $values['to']; + } + + $this->response->html($this->layout('analytic/lead_cycle_time', array( + 'values' => array( + 'from' => $from, + 'to' => $to, + ), + 'project' => $project, + 'average' => $this->projectAnalytic->getAverageLeadAndCycleTime($project['id']), + 'metrics' => $this->projectDailyStats->getRawMetrics($project['id'], $from, $to), + 'date_format' => $this->config->get('application_date_format'), + 'date_formats' => $this->dateParser->getAvailableFormats(), + 'title' => t('Lead and Cycle time for "%s"', $project['name']), + ))); + } + /** * Show average time spent by column * @@ -104,6 +138,8 @@ class Analytic extends Base $project = $this->getProject(); $values = $this->request->getValues(); + $this->projectDailyColumnStats->updateTotals($project['id'], date('Y-m-d')); + $from = $this->request->getStringParam('from', date('Y-m-d', strtotime('-1week'))); $to = $this->request->getStringParam('to', date('Y-m-d')); @@ -112,7 +148,7 @@ class Analytic extends Base $to = $values['to']; } - $display_graph = $this->projectDailySummary->countDays($project['id'], $from, $to) >= 2; + $display_graph = $this->projectDailyColumnStats->countDays($project['id'], $from, $to) >= 2; $this->response->html($this->layout($template, array( 'values' => array( @@ -120,7 +156,7 @@ class Analytic extends Base 'to' => $to, ), 'display_graph' => $display_graph, - 'metrics' => $display_graph ? $this->projectDailySummary->getAggregatedMetrics($project['id'], $from, $to, $column) : array(), + 'metrics' => $display_graph ? $this->projectDailyColumnStats->getAggregatedMetrics($project['id'], $from, $to, $column) : array(), 'project' => $project, 'date_format' => $this->config->get('application_date_format'), 'date_formats' => $this->dateParser->getAvailableFormats(), diff --git a/app/Controller/Export.php b/app/Controller/Export.php index 117fb5ee..8b558c0a 100644 --- a/app/Controller/Export.php +++ b/app/Controller/Export.php @@ -70,7 +70,7 @@ class Export extends Base */ public function summary() { - $this->common('projectDailySummary', 'getAggregatedMetrics', t('Summary'), 'summary', t('Daily project summary export')); + $this->common('ProjectDailyColumnStats', 'getAggregatedMetrics', t('Summary'), 'summary', t('Daily project summary export')); } /** diff --git a/app/Core/Base.php b/app/Core/Base.php index d4d7faa3..14466d5c 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -48,7 +48,8 @@ use Pimple\Container; * @property \Model\ProjectActivity $projectActivity * @property \Model\ProjectAnalytic $projectAnalytic * @property \Model\ProjectDuplication $projectDuplication - * @property \Model\ProjectDailySummary $projectDailySummary + * @property \Model\ProjectDailyColumnStats $projectDailyColumnStats + * @property \Model\ProjectDailyStats $projectDailyStats * @property \Model\ProjectIntegration $projectIntegration * @property \Model\ProjectPermission $projectPermission * @property \Model\Subtask $subtask diff --git a/app/Model/ProjectAnalytic.php b/app/Model/ProjectAnalytic.php index f4e8af09..1ee8a405 100644 --- a/app/Model/ProjectAnalytic.php +++ b/app/Model/ProjectAnalytic.php @@ -88,6 +88,43 @@ class ProjectAnalytic extends Base return array_values($metrics); } + /** + * Get the average lead and cycle time + * + * @access public + * @param integer $project_id + * @return array + */ + public function getAverageLeadAndCycleTime($project_id) + { + $stats = array( + 'count' => 0, + 'total_lead_time' => 0, + 'total_cycle_time' => 0, + 'avg_lead_time' => 0, + 'avg_cycle_time' => 0, + ); + + $tasks = $this->db + ->table(Task::TABLE) + ->columns('date_completed', 'date_creation', 'date_started') + ->eq('project_id', $project_id) + ->desc('id') + ->limit(1000) + ->findAll(); + + foreach ($tasks as &$task) { + $stats['count']++; + $stats['total_lead_time'] += ($task['date_completed'] ?: time()) - $task['date_creation']; + $stats['total_cycle_time'] += empty($task['date_started']) ? 0 : ($task['date_completed'] ?: time()) - $task['date_started']; + } + + $stats['avg_lead_time'] = (int) ($stats['total_lead_time'] / $stats['count']); + $stats['avg_cycle_time'] = (int) ($stats['total_cycle_time'] / $stats['count']); + + return $stats; + } + /** * Get the average time spent into each column * diff --git a/app/Model/ProjectDailyColumnStats.php b/app/Model/ProjectDailyColumnStats.php new file mode 100644 index 00000000..26e9d8b7 --- /dev/null +++ b/app/Model/ProjectDailyColumnStats.php @@ -0,0 +1,193 @@ +db->transaction(function($db) use ($project_id, $date) { + + $column_ids = $db->table(Board::TABLE)->eq('project_id', $project_id)->findAllByColumn('id'); + + foreach ($column_ids as $column_id) { + + // This call will fail if the record already exists + // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE) + $db->table(ProjectDailyColumnStats::TABLE)->insert(array( + 'day' => $date, + 'project_id' => $project_id, + 'column_id' => $column_id, + 'total' => 0, + 'score' => 0, + )); + + $db->table(ProjectDailyColumnStats::TABLE) + ->eq('project_id', $project_id) + ->eq('column_id', $column_id) + ->eq('day', $date) + ->update(array( + 'score' => $db->table(Task::TABLE) + ->eq('project_id', $project_id) + ->eq('column_id', $column_id) + ->eq('is_active', Task::STATUS_OPEN) + ->sum('score'), + 'total' => $db->table(Task::TABLE) + ->eq('project_id', $project_id) + ->eq('column_id', $column_id) + ->count() + )); + } + }); + } + + /** + * Count the number of recorded days for the data range + * + * @access public + * @param integer $project_id Project id + * @param string $from Start date (ISO format YYYY-MM-DD) + * @param string $to End date + * @return integer + */ + public function countDays($project_id, $from, $to) + { + $rq = $this->db->execute( + 'SELECT COUNT(DISTINCT day) FROM '.self::TABLE.' WHERE day >= ? AND day <= ? AND project_id=?', + array($from, $to, $project_id) + ); + + return $rq !== false ? $rq->fetchColumn(0) : 0; + } + + /** + * Get raw metrics for the project within a data range + * + * @access public + * @param integer $project_id Project id + * @param string $from Start date (ISO format YYYY-MM-DD) + * @param string $to End date + * @return array + */ + public function getRawMetrics($project_id, $from, $to) + { + return $this->db->table(ProjectDailyColumnStats::TABLE) + ->columns( + ProjectDailyColumnStats::TABLE.'.column_id', + ProjectDailyColumnStats::TABLE.'.day', + ProjectDailyColumnStats::TABLE.'.total', + ProjectDailyColumnStats::TABLE.'.score', + Board::TABLE.'.title AS column_title' + ) + ->join(Board::TABLE, 'id', 'column_id') + ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id) + ->gte('day', $from) + ->lte('day', $to) + ->asc(ProjectDailyColumnStats::TABLE.'.day') + ->findAll(); + } + + /** + * Get raw metrics for the project within a data range grouped by day + * + * @access public + * @param integer $project_id Project id + * @param string $from Start date (ISO format YYYY-MM-DD) + * @param string $to End date + * @return array + */ + public function getRawMetricsByDay($project_id, $from, $to) + { + return $this->db->table(ProjectDailyColumnStats::TABLE) + ->columns( + ProjectDailyColumnStats::TABLE.'.day', + 'SUM('.ProjectDailyColumnStats::TABLE.'.total) AS total', + 'SUM('.ProjectDailyColumnStats::TABLE.'.score) AS score' + ) + ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id) + ->gte('day', $from) + ->lte('day', $to) + ->asc(ProjectDailyColumnStats::TABLE.'.day') + ->groupBy(ProjectDailyColumnStats::TABLE.'.day') + ->findAll(); + } + + /** + * Get aggregated metrics for the project within a data range + * + * [ + * ['Date', 'Column1', 'Column2'], + * ['2014-11-16', 2, 5], + * ['2014-11-17', 20, 15], + * ] + * + * @access public + * @param integer $project_id Project id + * @param string $from Start date (ISO format YYYY-MM-DD) + * @param string $to End date + * @param string $column Column to aggregate + * @return array + */ + public function getAggregatedMetrics($project_id, $from, $to, $column = 'total') + { + $columns = $this->board->getColumnsList($project_id); + $column_ids = array_keys($columns); + $metrics = array(array_merge(array(e('Date')), array_values($columns))); + $aggregates = array(); + + // Fetch metrics for the project + $records = $this->db->table(ProjectDailyColumnStats::TABLE) + ->eq('project_id', $project_id) + ->gte('day', $from) + ->lte('day', $to) + ->findAll(); + + // Aggregate by day + foreach ($records as $record) { + + if (! isset($aggregates[$record['day']])) { + $aggregates[$record['day']] = array($record['day']); + } + + $aggregates[$record['day']][$record['column_id']] = $record[$column]; + } + + // Aggregate by row + foreach ($aggregates as $aggregate) { + + $row = array($aggregate[0]); + + foreach ($column_ids as $column_id) { + $row[] = (int) $aggregate[$column_id]; + } + + $metrics[] = $row; + } + + return $metrics; + } +} diff --git a/app/Model/ProjectDailyStats.php b/app/Model/ProjectDailyStats.php new file mode 100644 index 00000000..56a51730 --- /dev/null +++ b/app/Model/ProjectDailyStats.php @@ -0,0 +1,72 @@ +projectAnalytic->getAverageLeadAndCycleTime($project_id); + + return $this->db->transaction(function($db) use ($project_id, $date, $lead_cycle_time) { + + // This call will fail if the record already exists + // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE) + $db->table(ProjectDailyStats::TABLE)->insert(array( + 'day' => $date, + 'project_id' => $project_id, + 'avg_lead_time' => 0, + 'avg_cycle_time' => 0, + )); + + $db->table(ProjectDailyStats::TABLE) + ->eq('project_id', $project_id) + ->eq('day', $date) + ->update(array( + 'avg_lead_time' => $lead_cycle_time['avg_lead_time'], + 'avg_cycle_time' => $lead_cycle_time['avg_cycle_time'], + )); + }); + } + + /** + * Get raw metrics for the project within a data range + * + * @access public + * @param integer $project_id Project id + * @param string $from Start date (ISO format YYYY-MM-DD) + * @param string $to End date + * @return array + */ + public function getRawMetrics($project_id, $from, $to) + { + return $this->db->table(self::TABLE) + ->columns('day', 'avg_lead_time', 'avg_cycle_time') + ->eq(self::TABLE.'.project_id', $project_id) + ->gte('day', $from) + ->lte('day', $to) + ->asc(self::TABLE.'.day') + ->findAll(); + } +} diff --git a/app/Model/ProjectDailySummary.php b/app/Model/ProjectDailySummary.php deleted file mode 100644 index 04dc5629..00000000 --- a/app/Model/ProjectDailySummary.php +++ /dev/null @@ -1,193 +0,0 @@ -db->transaction(function($db) use ($project_id, $date) { - - $column_ids = $db->table(Board::TABLE)->eq('project_id', $project_id)->findAllByColumn('id'); - - foreach ($column_ids as $column_id) { - - // This call will fail if the record already exists - // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE) - $db->table(ProjectDailySummary::TABLE)->insert(array( - 'day' => $date, - 'project_id' => $project_id, - 'column_id' => $column_id, - 'total' => 0, - 'score' => 0, - )); - - $db->table(ProjectDailySummary::TABLE) - ->eq('project_id', $project_id) - ->eq('column_id', $column_id) - ->eq('day', $date) - ->update(array( - 'score' => $db->table(Task::TABLE) - ->eq('project_id', $project_id) - ->eq('column_id', $column_id) - ->eq('is_active', Task::STATUS_OPEN) - ->sum('score'), - 'total' => $db->table(Task::TABLE) - ->eq('project_id', $project_id) - ->eq('column_id', $column_id) - ->count() - )); - } - }); - } - - /** - * Count the number of recorded days for the data range - * - * @access public - * @param integer $project_id Project id - * @param string $from Start date (ISO format YYYY-MM-DD) - * @param string $to End date - * @return integer - */ - public function countDays($project_id, $from, $to) - { - $rq = $this->db->execute( - 'SELECT COUNT(DISTINCT day) FROM '.self::TABLE.' WHERE day >= ? AND day <= ? AND project_id=?', - array($from, $to, $project_id) - ); - - return $rq !== false ? $rq->fetchColumn(0) : 0; - } - - /** - * Get raw metrics for the project within a data range - * - * @access public - * @param integer $project_id Project id - * @param string $from Start date (ISO format YYYY-MM-DD) - * @param string $to End date - * @return array - */ - public function getRawMetrics($project_id, $from, $to) - { - return $this->db->table(ProjectDailySummary::TABLE) - ->columns( - ProjectDailySummary::TABLE.'.column_id', - ProjectDailySummary::TABLE.'.day', - ProjectDailySummary::TABLE.'.total', - ProjectDailySummary::TABLE.'.score', - Board::TABLE.'.title AS column_title' - ) - ->join(Board::TABLE, 'id', 'column_id') - ->eq(ProjectDailySummary::TABLE.'.project_id', $project_id) - ->gte('day', $from) - ->lte('day', $to) - ->asc(ProjectDailySummary::TABLE.'.day') - ->findAll(); - } - - /** - * Get raw metrics for the project within a data range grouped by day - * - * @access public - * @param integer $project_id Project id - * @param string $from Start date (ISO format YYYY-MM-DD) - * @param string $to End date - * @return array - */ - public function getRawMetricsByDay($project_id, $from, $to) - { - return $this->db->table(ProjectDailySummary::TABLE) - ->columns( - ProjectDailySummary::TABLE.'.day', - 'SUM('.ProjectDailySummary::TABLE.'.total) AS total', - 'SUM('.ProjectDailySummary::TABLE.'.score) AS score' - ) - ->eq(ProjectDailySummary::TABLE.'.project_id', $project_id) - ->gte('day', $from) - ->lte('day', $to) - ->asc(ProjectDailySummary::TABLE.'.day') - ->groupBy(ProjectDailySummary::TABLE.'.day') - ->findAll(); - } - - /** - * Get aggregated metrics for the project within a data range - * - * [ - * ['Date', 'Column1', 'Column2'], - * ['2014-11-16', 2, 5], - * ['2014-11-17', 20, 15], - * ] - * - * @access public - * @param integer $project_id Project id - * @param string $from Start date (ISO format YYYY-MM-DD) - * @param string $to End date - * @param string $column Column to aggregate - * @return array - */ - public function getAggregatedMetrics($project_id, $from, $to, $column = 'total') - { - $columns = $this->board->getColumnsList($project_id); - $column_ids = array_keys($columns); - $metrics = array(array_merge(array(e('Date')), array_values($columns))); - $aggregates = array(); - - // Fetch metrics for the project - $records = $this->db->table(ProjectDailySummary::TABLE) - ->eq('project_id', $project_id) - ->gte('day', $from) - ->lte('day', $to) - ->findAll(); - - // Aggregate by day - foreach ($records as $record) { - - if (! isset($aggregates[$record['day']])) { - $aggregates[$record['day']] = array($record['day']); - } - - $aggregates[$record['day']][$record['column_id']] = $record[$column]; - } - - // Aggregate by row - foreach ($aggregates as $aggregate) { - - $row = array($aggregate[0]); - - foreach ($column_ids as $column_id) { - $row[] = (int) $aggregate[$column_id]; - } - - $metrics[] = $row; - } - - return $metrics; - } -} diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php index 37ef637b..769a7425 100644 --- a/app/Schema/Mysql.php +++ b/app/Schema/Mysql.php @@ -6,7 +6,26 @@ use PDO; use Core\Security; use Model\Link; -const VERSION = 78; +const VERSION = 79; + +function version_79($pdo) +{ + $pdo->exec(" + CREATE TABLE project_daily_stats ( + id INT NOT NULL AUTO_INCREMENT, + day CHAR(10) NOT NULL, + project_id INT NOT NULL, + avg_lead_time INT NOT NULL DEFAULT 0, + avg_cycle_time INT NOT NULL DEFAULT 0, + PRIMARY KEY(id), + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE + ) ENGINE=InnoDB CHARSET=utf8 + "); + + $pdo->exec('CREATE UNIQUE INDEX project_daily_stats_idx ON project_daily_stats(day, project_id)'); + + $pdo->exec('RENAME TABLE project_daily_summaries TO project_daily_column_stats'); +} function version_78($pdo) { diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php index 62f22baa..fca87766 100644 --- a/app/Schema/Postgres.php +++ b/app/Schema/Postgres.php @@ -6,7 +6,25 @@ use PDO; use Core\Security; use Model\Link; -const VERSION = 58; +const VERSION = 59; + +function version_59($pdo) +{ + $pdo->exec(" + CREATE TABLE project_daily_stats ( + id SERIAL PRIMARY KEY, + day CHAR(10) NOT NULL, + project_id INTEGER NOT NULL, + avg_lead_time INTEGER NOT NULL DEFAULT 0, + avg_cycle_time INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE + ) + "); + + $pdo->exec('CREATE UNIQUE INDEX project_daily_stats_idx ON project_daily_stats(day, project_id)'); + + $pdo->exec('ALTER TABLE project_daily_summaries RENAME TO project_daily_column_stats'); +} function version_58($pdo) { diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php index 10899b54..9981e72f 100644 --- a/app/Schema/Sqlite.php +++ b/app/Schema/Sqlite.php @@ -6,7 +6,25 @@ use Core\Security; use PDO; use Model\Link; -const VERSION = 74; +const VERSION = 75; + +function version_75($pdo) +{ + $pdo->exec(" + CREATE TABLE project_daily_stats ( + id INTEGER PRIMARY KEY, + day TEXT NOT NULL, + project_id INTEGER NOT NULL, + avg_lead_time INTEGER NOT NULL DEFAULT 0, + avg_cycle_time INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE + ) + "); + + $pdo->exec('CREATE UNIQUE INDEX project_daily_stats_idx ON project_daily_stats(day, project_id)'); + + $pdo->exec('ALTER TABLE project_daily_summaries RENAME TO project_daily_column_stats'); +} function version_74($pdo) { diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index 609f5824..8c55457d 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -33,7 +33,8 @@ class ClassProvider implements ServiceProviderInterface 'ProjectActivity', 'ProjectAnalytic', 'ProjectDuplication', - 'ProjectDailySummary', + 'ProjectDailyColumnStats', + 'ProjectDailyStats', 'ProjectIntegration', 'ProjectPermission', 'Subtask', diff --git a/app/Subscriber/ProjectDailySummarySubscriber.php b/app/Subscriber/ProjectDailySummarySubscriber.php index 9e4f15b0..db180dea 100644 --- a/app/Subscriber/ProjectDailySummarySubscriber.php +++ b/app/Subscriber/ProjectDailySummarySubscriber.php @@ -22,7 +22,8 @@ class ProjectDailySummarySubscriber extends \Core\Base implements EventSubscribe public function execute(TaskEvent $event) { if (isset($event['project_id'])) { - $this->projectDailySummary->updateTotals($event['project_id'], date('Y-m-d')); + $this->projectDailyColumnStats->updateTotals($event['project_id'], date('Y-m-d')); + $this->projectDailyStats->updateTotals($event['project_id'], date('Y-m-d')); } } } diff --git a/app/Template/analytic/lead_cycle_time.php b/app/Template/analytic/lead_cycle_time.php new file mode 100644 index 00000000..d96bdcb8 --- /dev/null +++ b/app/Template/analytic/lead_cycle_time.php @@ -0,0 +1,42 @@ + + +
    +
      +
    • '.$this->dt->duration($average['avg_lead_time']) ?>
    • +
    • '.$this->dt->duration($average['avg_cycle_time']) ?>
    • +
    +
    + + +

    + +
    + +
    + +
    + + form->csrf() ?> + +
    + form->label(t('Start Date'), 'from') ?> + form->text('from', $values, array(), array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?> +
    + +
    + form->label(t('End Date'), 'to') ?> + form->text('to', $values, array(), array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?> +
    + +
    + +
    +
    + +

    + +

    +
    + diff --git a/app/Template/analytic/sidebar.php b/app/Template/analytic/sidebar.php index 1ffce710..de03bdf8 100644 --- a/app/Template/analytic/sidebar.php +++ b/app/Template/analytic/sidebar.php @@ -16,5 +16,8 @@
  • url->link(t('Average time into each column'), 'analytic', 'averageTimeByColumn', array('project_id' => $project['id'])) ?>
  • +
  • + url->link(t('Lead and cycle time'), 'analytic', 'leadAndCycleTime', array('project_id' => $project['id'])) ?> +
  • \ No newline at end of file diff --git a/assets/js/app.js b/assets/js/app.js index f8c65007..4210b723 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -147,24 +147,25 @@ Mousetrap.bind("b",function(b){b.preventDefault();$("#board-selector").trigger(" $(".task-autocomplete").parent().find("input[type=submit]").attr("disabled","disabled"),$(".task-autocomplete").autocomplete({source:$(".task-autocomplete").data("search-url"),minLength:1,select:function(b,a){var c=$(".task-autocomplete").data("dst-field");$("input[name="+c+"]").val(a.item.id);$(".task-autocomplete").parent().find("input[type=submit]").removeAttr("disabled")}}));$(".tooltip").tooltip({content:function(){return'
    '+$(this).attr("title")+"
    "},position:{my:"left-20 top", at:"center bottom+9",using:function(b,a){$(this).css(b);var c=a.target.left+a.target.width/2-a.element.left-20;$("
    ").addClass("tooltip-arrow").addClass(a.vertical).addClass(1>c?"align-left":"align-right").appendTo(this)}}});Kanboard.Exists("screenshot-zone")&&Kanboard.Screenshot.Init()}}}(); (function(){function b(a){a.preventDefault();a.stopPropagation();Kanboard.Popover(a,Kanboard.InitAfterAjax)}function a(){Mousetrap.bind("n",function(){Kanboard.OpenPopover($("#board").data("task-creation-url"),Kanboard.InitAfterAjax)});Mousetrap.bind("s",function(){"expanded"===(Kanboard.GetStorageItem(e())||"expanded")?(d(),Kanboard.SetStorageItem(e(),"collapsed")):(f(),Kanboard.SetStorageItem(e(),"expanded"))});Mousetrap.bind("c",function(){n()})}function c(){$(".filter-expand-link").click(function(a){a.preventDefault(); -f();Kanboard.SetStorageItem(e(),"expanded")});$(".filter-collapse-link").click(function(a){a.preventDefault();d();Kanboard.SetStorageItem(e(),"collapsed")});k()}function e(){return"board_stacking_"+$("#board").data("project-id")}function d(){$(".filter-collapse").hide();$(".task-board-collapsed").show();$(".filter-expand").show();$(".task-board-expanded").hide()}function f(){$(".filter-collapse").show();$(".task-board-collapsed").hide();$(".filter-expand").hide();$(".task-board-expanded").show()} -function k(){"expanded"===(Kanboard.GetStorageItem(e())||"expanded")?f():d()}function l(){$(".column").sortable({delay:300,distance:5,connectWith:".column",placeholder:"draggable-placeholder",items:".draggable-item",stop:function(a,b){g(b.item.attr("data-task-id"),b.item.parent().attr("data-column-id"),b.item.index()+1,b.item.parent().attr("data-swimlane-id"))}});$("#board").on("click",".task-board-popover",b);$("#board").on("click",".task-board",function(){window.location=$(this).data("task-url")}); -$(".task-board-tooltip").tooltip({track:!1,position:{my:"left-20 top",at:"center bottom+9",using:function(a,b){$(this).css(a);var c=b.target.left+b.target.width/2-b.element.left-20;$("
    ").addClass("tooltip-arrow").addClass(b.vertical).addClass(1>c?"align-left":"align-right").appendTo(this)}},content:function(a){if(a=$(this).attr("data-href")){var b=this;$.get(a,function p(a){$(".ui-tooltip-content:visible").html(a);a=$(".ui-tooltip:visible");a.css({top:"",left:""});a.children(".tooltip-arrow").remove(); -var c=$(b).tooltip("option","position");c.of=$(b);a.position(c);$("#tooltip-subtasks a").not(".popover").click(function(a){a.preventDefault();a.stopPropagation();$(this).hasClass("popover-subtask-restriction")?(Kanboard.OpenPopover($(this).attr("href")),$(b).tooltip("close")):$.get($(this).attr("href"),p)})});return''}}}).on("mouseenter",function(){var a=this;$(this).tooltip("open");$(".ui-tooltip").on("mouseleave",function(){$(a).tooltip("close")})}).on("mouseleave focusout", -function(a){a.stopImmediatePropagation();var b=this;setTimeout(function(){$(".ui-tooltip:hover").length||$(b).tooltip("close")},100)});var a=parseInt($("#board").attr("data-check-interval"));0").addClass("tooltip-arrow").addClass(c.vertical).addClass(1>b?"align-left":"align-right").appendTo(this)}},content:function(a){if(a=$(this).attr("data-href")){var c=this;$.get(a,function p(a){$(".ui-tooltip-content:visible").html(a);a=$(".ui-tooltip:visible");a.css({top:"",left:""});a.children(".tooltip-arrow").remove(); +var b=$(c).tooltip("option","position");b.of=$(c);a.position(b);$("#tooltip-subtasks a").not(".popover").click(function(a){a.preventDefault();a.stopPropagation();$(this).hasClass("popover-subtask-restriction")?(Kanboard.OpenPopover($(this).attr("href")),$(c).tooltip("close")):$.get($(this).attr("href"),p)})});return''}}}).on("mouseenter",function(){var a=this;$(this).tooltip("open");$(".ui-tooltip").on("mouseleave",function(){$(a).tooltip("close")})}).on("mouseleave focusout", +function(a){a.stopImmediatePropagation();var c=this;setTimeout(function(){$(".ui-tooltip:hover").length||$(c).tooltip("close")},100)});var a=parseInt($("#board").attr("data-check-interval"));0el día",eventLimitText:"más"})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){function n(e,t,n,r){var s="";switch(n){case"s":return r?"muutaman sekunnin":"muutama sekunti";case"m":return r?"minuutin":"minuutti";case"mm":s=r?"minuutin":"minuuttia";break;case"h":return r?"tunnin":"tunti";case"hh":s=r?"tunnin":"tuntia";break;case"d":return r?"päivän":"päivä";case"dd":s=r?"päivän":"päivää";break;case"M":return r?"kuukauden":"kuukausi";case"MM":s=r?"kuukauden":"kuukautta";break;case"y":return r?"vuoden":"vuosi";case"yy":s=r?"vuoden":"vuotta"}return s=i(e,r)+" "+s}function i(e,t){return 10>e?t?s[e]:r[e]:e}var r="nolla yksi kaksi kolme neljä viisi kuusi seitsemän kahdeksan yhdeksän".split(" "),s=["nolla","yhden","kahden","kolmen","neljän","viiden","kuuden",r[7],r[8],r[9]];(t.defineLocale||t.lang).call(t,"fi",{months:"tammikuu_helmikuu_maaliskuu_huhtikuu_toukokuu_kesäkuu_heinäkuu_elokuu_syyskuu_lokakuu_marraskuu_joulukuu".split("_"),monthsShort:"tammi_helmi_maalis_huhti_touko_kesä_heinä_elo_syys_loka_marras_joulu".split("_"),weekdays:"sunnuntai_maanantai_tiistai_keskiviikko_torstai_perjantai_lauantai".split("_"),weekdaysShort:"su_ma_ti_ke_to_pe_la".split("_"),weekdaysMin:"su_ma_ti_ke_to_pe_la".split("_"),longDateFormat:{LT:"HH.mm",LTS:"HH.mm.ss",L:"DD.MM.YYYY",LL:"Do MMMM[ta] YYYY",LLL:"Do MMMM[ta] YYYY, [klo] LT",LLLL:"dddd, Do MMMM[ta] YYYY, [klo] LT",l:"D.M.YYYY",ll:"Do MMM YYYY",lll:"Do MMM YYYY, [klo] LT",llll:"ddd, Do MMM YYYY, [klo] LT"},calendar:{sameDay:"[tänään] [klo] LT",nextDay:"[huomenna] [klo] LT",nextWeek:"dddd [klo] LT",lastDay:"[eilen] [klo] LT",lastWeek:"[viime] dddd[na] [klo] LT",sameElse:"L"},relativeTime:{future:"%s päästä",past:"%s sitten",s:n,m:n,mm:n,h:n,hh:n,d:n,dd:n,M:n,MM:n,y:n,yy:n},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),e.fullCalendar.datepickerLang("fi","fi",{closeText:"Sulje",prevText:"«Edellinen",nextText:"Seuraava»",currentText:"Tänään",monthNames:["Tammikuu","Helmikuu","Maaliskuu","Huhtikuu","Toukokuu","Kesäkuu","Heinäkuu","Elokuu","Syyskuu","Lokakuu","Marraskuu","Joulukuu"],monthNamesShort:["Tammi","Helmi","Maalis","Huhti","Touko","Kesä","Heinä","Elo","Syys","Loka","Marras","Joulu"],dayNamesShort:["Su","Ma","Ti","Ke","To","Pe","La"],dayNames:["Sunnuntai","Maanantai","Tiistai","Keskiviikko","Torstai","Perjantai","Lauantai"],dayNamesMin:["Su","Ma","Ti","Ke","To","Pe","La"],weekHeader:"Vk",dateFormat:"d.m.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("fi",{defaultButtonText:{month:"Kuukausi",week:"Viikko",day:"Päivä",list:"Tapahtumat"},allDayText:"Koko päivä",eventLimitText:"lisää"})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){(t.defineLocale||t.lang).call(t,"fr",{months:"janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split("_"),monthsShort:"janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split("_"),weekdays:"dimanche_lundi_mardi_mercredi_jeudi_vendredi_samedi".split("_"),weekdaysShort:"dim._lun._mar._mer._jeu._ven._sam.".split("_"),weekdaysMin:"Di_Lu_Ma_Me_Je_Ve_Sa".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[Aujourd'hui à] LT",nextDay:"[Demain à] LT",nextWeek:"dddd [à] LT",lastDay:"[Hier à] LT",lastWeek:"dddd [dernier à] LT",sameElse:"L"},relativeTime:{future:"dans %s",past:"il y a %s",s:"quelques secondes",m:"une minute",mm:"%d minutes",h:"une heure",hh:"%d heures",d:"un jour",dd:"%d jours",M:"un mois",MM:"%d mois",y:"un an",yy:"%d ans"},ordinalParse:/\d{1,2}(er|)/,ordinal:function(e){return e+(1===e?"er":"")},week:{dow:1,doy:4}}),e.fullCalendar.datepickerLang("fr","fr",{closeText:"Fermer",prevText:"Précédent",nextText:"Suivant",currentText:"Aujourd'hui",monthNames:["janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"],monthNamesShort:["janv.","févr.","mars","avr.","mai","juin","juil.","août","sept.","oct.","nov.","déc."],dayNames:["dimanche","lundi","mardi","mercredi","jeudi","vendredi","samedi"],dayNamesShort:["dim.","lun.","mar.","mer.","jeu.","ven.","sam."],dayNamesMin:["D","L","M","M","J","V","S"],weekHeader:"Sem.",dateFormat:"dd/mm/yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("fr",{defaultButtonText:{month:"Mois",week:"Semaine",day:"Jour",list:"Mon planning"},allDayHtml:"Toute la
    journée",eventLimitText:"en plus"})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){function n(e,t,n,r){var i=e;switch(n){case"s":return r||t?"néhány másodperc":"néhány másodperce";case"m":return"egy"+(r||t?" perc":" perce");case"mm":return i+(r||t?" perc":" perce");case"h":return"egy"+(r||t?" óra":" órája");case"hh":return i+(r||t?" óra":" órája");case"d":return"egy"+(r||t?" nap":" napja");case"dd":return i+(r||t?" nap":" napja");case"M":return"egy"+(r||t?" hónap":" hónapja");case"MM":return i+(r||t?" hónap":" hónapja");case"y":return"egy"+(r||t?" év":" éve");case"yy":return i+(r||t?" év":" éve")}return""}function r(e){return(e?"":"[múlt] ")+"["+i[this.day()]+"] LT[-kor]"}var i="vasárnap hétfőn kedden szerdán csütörtökön pénteken szombaton".split(" ");(t.defineLocale||t.lang).call(t,"hu",{months:"január_február_március_április_május_június_július_augusztus_szeptember_október_november_december".split("_"),monthsShort:"jan_feb_márc_ápr_máj_jún_júl_aug_szept_okt_nov_dec".split("_"),weekdays:"vasárnap_hétfő_kedd_szerda_csütörtök_péntek_szombat".split("_"),weekdaysShort:"vas_hét_kedd_sze_csüt_pén_szo".split("_"),weekdaysMin:"v_h_k_sze_cs_p_szo".split("_"),longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"YYYY.MM.DD.",LL:"YYYY. MMMM D.",LLL:"YYYY. MMMM D., LT",LLLL:"YYYY. MMMM D., dddd LT"},meridiemParse:/de|du/i,isPM:function(e){return"u"===e.charAt(1).toLowerCase()},meridiem:function(e,t,n){return 12>e?n===!0?"de":"DE":n===!0?"du":"DU"},calendar:{sameDay:"[ma] LT[-kor]",nextDay:"[holnap] LT[-kor]",nextWeek:function(){return r.call(this,!0)},lastDay:"[tegnap] LT[-kor]",lastWeek:function(){return r.call(this,!1)},sameElse:"L"},relativeTime:{future:"%s múlva",past:"%s",s:n,m:n,mm:n,h:n,hh:n,d:n,dd:n,M:n,MM:n,y:n,yy:n},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),e.fullCalendar.datepickerLang("hu","hu",{closeText:"bezár",prevText:"vissza",nextText:"előre",currentText:"ma",monthNames:["Január","Február","Március","Április","Május","Június","Július","Augusztus","Szeptember","Október","November","December"],monthNamesShort:["Jan","Feb","Már","Ápr","Máj","Jún","Júl","Aug","Szep","Okt","Nov","Dec"],dayNames:["Vasárnap","Hétfő","Kedd","Szerda","Csütörtök","Péntek","Szombat"],dayNamesShort:["Vas","Hét","Ked","Sze","Csü","Pén","Szo"],dayNamesMin:["V","H","K","Sze","Cs","P","Szo"],weekHeader:"Hét",dateFormat:"yy.mm.dd.",firstDay:1,isRTL:!1,showMonthAfterYear:!0,yearSuffix:""}),e.fullCalendar.lang("hu",{defaultButtonText:{month:"Hónap",week:"Hét",day:"Nap",list:"Napló"},allDayText:"Egész nap",eventLimitText:"további"})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){(t.defineLocale||t.lang).call(t,"it",{months:"gennaio_febbraio_marzo_aprile_maggio_giugno_luglio_agosto_settembre_ottobre_novembre_dicembre".split("_"),monthsShort:"gen_feb_mar_apr_mag_giu_lug_ago_set_ott_nov_dic".split("_"),weekdays:"Domenica_Lunedì_Martedì_Mercoledì_Giovedì_Venerdì_Sabato".split("_"),weekdaysShort:"Dom_Lun_Mar_Mer_Gio_Ven_Sab".split("_"),weekdaysMin:"D_L_Ma_Me_G_V_S".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Oggi alle] LT",nextDay:"[Domani alle] LT",nextWeek:"dddd [alle] LT",lastDay:"[Ieri alle] LT",lastWeek:function(){switch(this.day()){case 0:return"[la scorsa] dddd [alle] LT";default:return"[lo scorso] dddd [alle] LT"}},sameElse:"L"},relativeTime:{future:function(e){return(/^[0-9].+$/.test(e)?"tra":"in")+" "+e},past:"%s fa",s:"alcuni secondi",m:"un minuto",mm:"%d minuti",h:"un'ora",hh:"%d ore",d:"un giorno",dd:"%d giorni",M:"un mese",MM:"%d mesi",y:"un anno",yy:"%d anni"},ordinalParse:/\d{1,2}º/,ordinal:"%dº",week:{dow:1,doy:4}}),e.fullCalendar.datepickerLang("it","it",{closeText:"Chiudi",prevText:"<Prec",nextText:"Succ>",currentText:"Oggi",monthNames:["Gennaio","Febbraio","Marzo","Aprile","Maggio","Giugno","Luglio","Agosto","Settembre","Ottobre","Novembre","Dicembre"],monthNamesShort:["Gen","Feb","Mar","Apr","Mag","Giu","Lug","Ago","Set","Ott","Nov","Dic"],dayNames:["Domenica","Lunedì","Martedì","Mercoledì","Giovedì","Venerdì","Sabato"],dayNamesShort:["Dom","Lun","Mar","Mer","Gio","Ven","Sab"],dayNamesMin:["Do","Lu","Ma","Me","Gi","Ve","Sa"],weekHeader:"Sm",dateFormat:"dd/mm/yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("it",{defaultButtonText:{month:"Mese",week:"Settimana",day:"Giorno",list:"Agenda"},allDayHtml:"Tutto il
    giorno",eventLimitText:function(e){return"+altri "+e}})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){(t.defineLocale||t.lang).call(t,"ja",{months:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),monthsShort:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),weekdays:"日曜日_月曜日_火曜日_水曜日_木曜日_金曜日_土曜日".split("_"),weekdaysShort:"日_月_火_水_木_金_土".split("_"),weekdaysMin:"日_月_火_水_木_金_土".split("_"),longDateFormat:{LT:"Ah時m分",LTS:"LTs秒",L:"YYYY/MM/DD",LL:"YYYY年M月D日",LLL:"YYYY年M月D日LT",LLLL:"YYYY年M月D日LT dddd"},meridiemParse:/午前|午後/i,isPM:function(e){return"午後"===e},meridiem:function(e){return 12>e?"午前":"午後"},calendar:{sameDay:"[今日] LT",nextDay:"[明日] LT",nextWeek:"[来週]dddd LT",lastDay:"[昨日] LT",lastWeek:"[前週]dddd LT",sameElse:"L"},relativeTime:{future:"%s後",past:"%s前",s:"数秒",m:"1分",mm:"%d分",h:"1時間",hh:"%d時間",d:"1日",dd:"%d日",M:"1ヶ月",MM:"%dヶ月",y:"1年",yy:"%d年"}}),e.fullCalendar.datepickerLang("ja","ja",{closeText:"閉じる",prevText:"<前",nextText:"次>",currentText:"今日",monthNames:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],monthNamesShort:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayNames:["日曜日","月曜日","火曜日","水曜日","木曜日","金曜日","土曜日"],dayNamesShort:["日","月","火","水","木","金","土"],dayNamesMin:["日","月","火","水","木","金","土"],weekHeader:"週",dateFormat:"yy/mm/dd",firstDay:0,isRTL:!1,showMonthAfterYear:!0,yearSuffix:"年"}),e.fullCalendar.lang("ja",{defaultButtonText:{month:"月",week:"週",day:"日",list:"予定リスト"},allDayText:"終日",eventLimitText:function(e){return"他 "+e+" 件"}})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){var n="jan._feb._mrt._apr._mei_jun._jul._aug._sep._okt._nov._dec.".split("_"),r="jan_feb_mrt_apr_mei_jun_jul_aug_sep_okt_nov_dec".split("_");(t.defineLocale||t.lang).call(t,"nl",{months:"januari_februari_maart_april_mei_juni_juli_augustus_september_oktober_november_december".split("_"),monthsShort:function(e,t){return/-MMM-/.test(t)?r[e.month()]:n[e.month()]},weekdays:"zondag_maandag_dinsdag_woensdag_donderdag_vrijdag_zaterdag".split("_"),weekdaysShort:"zo._ma._di._wo._do._vr._za.".split("_"),weekdaysMin:"Zo_Ma_Di_Wo_Do_Vr_Za".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD-MM-YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[vandaag om] LT",nextDay:"[morgen om] LT",nextWeek:"dddd [om] LT",lastDay:"[gisteren om] LT",lastWeek:"[afgelopen] dddd [om] LT",sameElse:"L"},relativeTime:{future:"over %s",past:"%s geleden",s:"een paar seconden",m:"één minuut",mm:"%d minuten",h:"één uur",hh:"%d uur",d:"één dag",dd:"%d dagen",M:"één maand",MM:"%d maanden",y:"één jaar",yy:"%d jaar"},ordinalParse:/\d{1,2}(ste|de)/,ordinal:function(e){return e+(1===e||8===e||e>=20?"ste":"de")},week:{dow:1,doy:4}}),e.fullCalendar.datepickerLang("nl","nl",{closeText:"Sluiten",prevText:"←",nextText:"→",currentText:"Vandaag",monthNames:["januari","februari","maart","april","mei","juni","juli","augustus","september","oktober","november","december"],monthNamesShort:["jan","feb","mrt","apr","mei","jun","jul","aug","sep","okt","nov","dec"],dayNames:["zondag","maandag","dinsdag","woensdag","donderdag","vrijdag","zaterdag"],dayNamesShort:["zon","maa","din","woe","don","vri","zat"],dayNamesMin:["zo","ma","di","wo","do","vr","za"],weekHeader:"Wk",dateFormat:"dd-mm-yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("nl",{defaultButtonText:{month:"Maand",week:"Week",day:"Dag",list:"Agenda"},allDayText:"Hele dag",eventLimitText:"extra"})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){function n(e){return 5>e%10&&e%10>1&&1!==~~(e/10)%10}function i(e,t,i){var r=e+" ";switch(i){case"m":return t?"minuta":"minutę";case"mm":return r+(n(e)?"minuty":"minut");case"h":return t?"godzina":"godzinę";case"hh":return r+(n(e)?"godziny":"godzin");case"MM":return r+(n(e)?"miesiące":"miesięcy");case"yy":return r+(n(e)?"lata":"lat")}}var r="styczeń_luty_marzec_kwiecień_maj_czerwiec_lipiec_sierpień_wrzesień_październik_listopad_grudzień".split("_"),a="stycznia_lutego_marca_kwietnia_maja_czerwca_lipca_sierpnia_września_października_listopada_grudnia".split("_");(t.defineLocale||t.lang).call(t,"pl",{months:function(e,t){return/D MMMM/.test(t)?a[e.month()]:r[e.month()]},monthsShort:"sty_lut_mar_kwi_maj_cze_lip_sie_wrz_paź_lis_gru".split("_"),weekdays:"niedziela_poniedziałek_wtorek_środa_czwartek_piątek_sobota".split("_"),weekdaysShort:"nie_pon_wt_śr_czw_pt_sb".split("_"),weekdaysMin:"N_Pn_Wt_Śr_Cz_Pt_So".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[Dziś o] LT",nextDay:"[Jutro o] LT",nextWeek:"[W] dddd [o] LT",lastDay:"[Wczoraj o] LT",lastWeek:function(){switch(this.day()){case 0:return"[W zeszłą niedzielę o] LT";case 3:return"[W zeszłą środę o] LT";case 6:return"[W zeszłą sobotę o] LT";default:return"[W zeszły] dddd [o] LT"}},sameElse:"L"},relativeTime:{future:"za %s",past:"%s temu",s:"kilka sekund",m:i,mm:i,h:i,hh:i,d:"1 dzień",dd:"%d dni",M:"miesiąc",MM:i,y:"rok",yy:i},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:4}}),e.fullCalendar.datepickerLang("pl","pl",{closeText:"Zamknij",prevText:"<Poprzedni",nextText:"Następny>",currentText:"Dziś",monthNames:["Styczeń","Luty","Marzec","Kwiecień","Maj","Czerwiec","Lipiec","Sierpień","Wrzesień","Październik","Listopad","Grudzień"],monthNamesShort:["Sty","Lu","Mar","Kw","Maj","Cze","Lip","Sie","Wrz","Pa","Lis","Gru"],dayNames:["Niedziela","Poniedziałek","Wtorek","Środa","Czwartek","Piątek","Sobota"],dayNamesShort:["Nie","Pn","Wt","Śr","Czw","Pt","So"],dayNamesMin:["N","Pn","Wt","Śr","Cz","Pt","So"],weekHeader:"Tydz",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("pl",{defaultButtonText:{month:"Miesiąc",week:"Tydzień",day:"Dzień",list:"Plan dnia"},allDayText:"Cały dzień",eventLimitText:"więcej"})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){(t.defineLocale||t.lang).call(t,"pt-br",{months:"janeiro_fevereiro_março_abril_maio_junho_julho_agosto_setembro_outubro_novembro_dezembro".split("_"),monthsShort:"jan_fev_mar_abr_mai_jun_jul_ago_set_out_nov_dez".split("_"),weekdays:"domingo_segunda-feira_terça-feira_quarta-feira_quinta-feira_sexta-feira_sábado".split("_"),weekdaysShort:"dom_seg_ter_qua_qui_sex_sáb".split("_"),weekdaysMin:"dom_2ª_3ª_4ª_5ª_6ª_sáb".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD/MM/YYYY",LL:"D [de] MMMM [de] YYYY",LLL:"D [de] MMMM [de] YYYY [às] LT",LLLL:"dddd, D [de] MMMM [de] YYYY [às] LT"},calendar:{sameDay:"[Hoje às] LT",nextDay:"[Amanhã às] LT",nextWeek:"dddd [às] LT",lastDay:"[Ontem às] LT",lastWeek:function(){return 0===this.day()||6===this.day()?"[Último] dddd [às] LT":"[Última] dddd [às] LT"},sameElse:"L"},relativeTime:{future:"em %s",past:"%s atrás",s:"segundos",m:"um minuto",mm:"%d minutos",h:"uma hora",hh:"%d horas",d:"um dia",dd:"%d dias",M:"um mês",MM:"%d meses",y:"um ano",yy:"%d anos"},ordinalParse:/\d{1,2}º/,ordinal:"%dº"}),e.fullCalendar.datepickerLang("pt-br","pt-BR",{closeText:"Fechar",prevText:"<Anterior",nextText:"Próximo>",currentText:"Hoje",monthNames:["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"],monthNamesShort:["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],dayNames:["Domingo","Segunda-feira","Terça-feira","Quarta-feira","Quinta-feira","Sexta-feira","Sábado"],dayNamesShort:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],dayNamesMin:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],weekHeader:"Sm",dateFormat:"dd/mm/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("pt-br",{defaultButtonText:{month:"Mês",week:"Semana",day:"Dia",list:"Compromissos"},allDayText:"dia inteiro",eventLimitText:function(e){return"mais +"+e}})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){function n(e,t){var n=e.split("_");return 1===t%10&&11!==t%100?n[0]:t%10>=2&&4>=t%10&&(10>t%100||t%100>=20)?n[1]:n[2]}function i(e,t,i){var a={mm:t?"минута_минуты_минут":"минуту_минуты_минут",hh:"час_часа_часов",dd:"день_дня_дней",MM:"месяц_месяца_месяцев",yy:"год_года_лет"};return"m"===i?t?"минута":"минуту":e+" "+n(a[i],+e)}function a(e,t){var n={nominative:"январь_февраль_март_апрель_май_июнь_июль_август_сентябрь_октябрь_ноябрь_декабрь".split("_"),accusative:"января_февраля_марта_апреля_мая_июня_июля_августа_сентября_октября_ноября_декабря".split("_")},i=/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/.test(t)?"accusative":"nominative";return n[i][e.month()]}function r(e,t){var n={nominative:"янв_фев_март_апр_май_июнь_июль_авг_сен_окт_ноя_дек".split("_"),accusative:"янв_фев_мар_апр_мая_июня_июля_авг_сен_окт_ноя_дек".split("_")},i=/D[oD]?(\[[^\[\]]*\]|\s+)+MMMM?/.test(t)?"accusative":"nominative";return n[i][e.month()]}function s(e,t){var n={nominative:"воскресенье_понедельник_вторник_среда_четверг_пятница_суббота".split("_"),accusative:"воскресенье_понедельник_вторник_среду_четверг_пятницу_субботу".split("_")},i=/\[ ?[Вв] ?(?:прошлую|следующую|эту)? ?\] ?dddd/.test(t)?"accusative":"nominative";return n[i][e.day()]}(t.defineLocale||t.lang).call(t,"ru",{months:a,monthsShort:r,weekdays:s,weekdaysShort:"вс_пн_вт_ср_чт_пт_сб".split("_"),weekdaysMin:"вс_пн_вт_ср_чт_пт_сб".split("_"),monthsParse:[/^янв/i,/^фев/i,/^мар/i,/^апр/i,/^ма[й|я]/i,/^июн/i,/^июл/i,/^авг/i,/^сен/i,/^окт/i,/^ноя/i,/^дек/i],longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY г.",LLL:"D MMMM YYYY г., LT",LLLL:"dddd, D MMMM YYYY г., LT"},calendar:{sameDay:"[Сегодня в] LT",nextDay:"[Завтра в] LT",lastDay:"[Вчера в] LT",nextWeek:function(){return 2===this.day()?"[Во] dddd [в] LT":"[В] dddd [в] LT"},lastWeek:function(e){if(e.week()===this.week())return 2===this.day()?"[Во] dddd [в] LT":"[В] dddd [в] LT";switch(this.day()){case 0:return"[В прошлое] dddd [в] LT";case 1:case 2:case 4:return"[В прошлый] dddd [в] LT";case 3:case 5:case 6:return"[В прошлую] dddd [в] LT"}},sameElse:"L"},relativeTime:{future:"через %s",past:"%s назад",s:"несколько секунд",m:i,mm:i,h:"час",hh:i,d:"день",dd:i,M:"месяц",MM:i,y:"год",yy:i},meridiemParse:/ночи|утра|дня|вечера/i,isPM:function(e){return/^(дня|вечера)$/.test(e)},meridiem:function(e){return 4>e?"ночи":12>e?"утра":17>e?"дня":"вечера"},ordinalParse:/\d{1,2}-(й|го|я)/,ordinal:function(e,t){switch(t){case"M":case"d":case"DDD":return e+"-й";case"D":return e+"-го";case"w":case"W":return e+"-я";default:return e}},week:{dow:1,doy:7}}),e.fullCalendar.datepickerLang("ru","ru",{closeText:"Закрыть",prevText:"<Пред",nextText:"След>",currentText:"Сегодня",monthNames:["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"],monthNamesShort:["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"],dayNames:["воскресенье","понедельник","вторник","среда","четверг","пятница","суббота"],dayNamesShort:["вск","пнд","втр","срд","чтв","птн","сбт"],dayNamesMin:["Вс","Пн","Вт","Ср","Чт","Пт","Сб"],weekHeader:"Нед",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("ru",{defaultButtonText:{month:"Месяц",week:"Неделя",day:"День",list:"Повестка дня"},allDayText:"Весь день",eventLimitText:function(e){return"+ ещё "+e}})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){(t.defineLocale||t.lang).call(t,"sv",{months:"januari_februari_mars_april_maj_juni_juli_augusti_september_oktober_november_december".split("_"),monthsShort:"jan_feb_mar_apr_maj_jun_jul_aug_sep_okt_nov_dec".split("_"),weekdays:"söndag_måndag_tisdag_onsdag_torsdag_fredag_lördag".split("_"),weekdaysShort:"sön_mån_tis_ons_tor_fre_lör".split("_"),weekdaysMin:"sö_må_ti_on_to_fr_lö".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"YYYY-MM-DD",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd D MMMM YYYY LT"},calendar:{sameDay:"[Idag] LT",nextDay:"[Imorgon] LT",lastDay:"[Igår] LT",nextWeek:"dddd LT",lastWeek:"[Förra] dddd[en] LT",sameElse:"L"},relativeTime:{future:"om %s",past:"för %s sedan",s:"några sekunder",m:"en minut",mm:"%d minuter",h:"en timme",hh:"%d timmar",d:"en dag",dd:"%d dagar",M:"en månad",MM:"%d månader",y:"ett år",yy:"%d år"},ordinalParse:/\d{1,2}(e|a)/,ordinal:function(e){var t=e%10,n=1===~~(e%100/10)?"e":1===t?"a":2===t?"a":3===t?"e":"e";return e+n},week:{dow:1,doy:4}}),e.fullCalendar.datepickerLang("sv","sv",{closeText:"Stäng",prevText:"«Förra",nextText:"Nästa»",currentText:"Idag",monthNames:["Januari","Februari","Mars","April","Maj","Juni","Juli","Augusti","September","Oktober","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","Maj","Jun","Jul","Aug","Sep","Okt","Nov","Dec"],dayNamesShort:["Sön","Mån","Tis","Ons","Tor","Fre","Lör"],dayNames:["Söndag","Måndag","Tisdag","Onsdag","Torsdag","Fredag","Lördag"],dayNamesMin:["Sö","Må","Ti","On","To","Fr","Lö"],weekHeader:"Ve",dateFormat:"yy-mm-dd",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("sv",{defaultButtonText:{month:"Månad",week:"Vecka",day:"Dag",list:"Program"},allDayText:"Heldag",eventLimitText:"till"})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){var n={words:{m:["jedan minut","jedne minute"],mm:["minut","minute","minuta"],h:["jedan sat","jednog sata"],hh:["sat","sata","sati"],dd:["dan","dana","dana"],MM:["mesec","meseca","meseci"],yy:["godina","godine","godina"]},correctGrammaticalCase:function(e,t){return 1===e?t[0]:e>=2&&4>=e?t[1]:t[2]},translate:function(e,t,a){var r=n.words[a];return 1===a.length?t?r[0]:r[1]:e+" "+n.correctGrammaticalCase(e,r)}};(t.defineLocale||t.lang).call(t,"sr",{months:["januar","februar","mart","april","maj","jun","jul","avgust","septembar","oktobar","novembar","decembar"],monthsShort:["jan.","feb.","mar.","apr.","maj","jun","jul","avg.","sep.","okt.","nov.","dec."],weekdays:["nedelja","ponedeljak","utorak","sreda","četvrtak","petak","subota"],weekdaysShort:["ned.","pon.","uto.","sre.","čet.","pet.","sub."],weekdaysMin:["ne","po","ut","sr","če","pe","su"],longDateFormat:{LT:"H:mm",LTS:"LT:ss",L:"DD. MM. YYYY",LL:"D. MMMM YYYY",LLL:"D. MMMM YYYY LT",LLLL:"dddd, D. MMMM YYYY LT"},calendar:{sameDay:"[danas u] LT",nextDay:"[sutra u] LT",nextWeek:function(){switch(this.day()){case 0:return"[u] [nedelju] [u] LT";case 3:return"[u] [sredu] [u] LT";case 6:return"[u] [subotu] [u] LT";case 1:case 2:case 4:case 5:return"[u] dddd [u] LT"}},lastDay:"[juče u] LT",lastWeek:function(){var e=["[prošle] [nedelje] [u] LT","[prošlog] [ponedeljka] [u] LT","[prošlog] [utorka] [u] LT","[prošle] [srede] [u] LT","[prošlog] [četvrtka] [u] LT","[prošlog] [petka] [u] LT","[prošle] [subote] [u] LT"];return e[this.day()]},sameElse:"L"},relativeTime:{future:"za %s",past:"pre %s",s:"nekoliko sekundi",m:n.translate,mm:n.translate,h:n.translate,hh:n.translate,d:"dan",dd:n.translate,M:"mesec",MM:n.translate,y:"godinu",yy:n.translate},ordinalParse:/\d{1,2}\./,ordinal:"%d.",week:{dow:1,doy:7}}),e.fullCalendar.datepickerLang("sr","sr",{closeText:"Затвори",prevText:"<",nextText:">",currentText:"Данас",monthNames:["Јануар","Фебруар","Март","Април","Мај","Јун","Јул","Август","Септембар","Октобар","Новембар","Децембар"],monthNamesShort:["Јан","Феб","Мар","Апр","Мај","Јун","Јул","Авг","Сеп","Окт","Нов","Дец"],dayNames:["Недеља","Понедељак","Уторак","Среда","Четвртак","Петак","Субота"],dayNamesShort:["Нед","Пон","Уто","Сре","Чет","Пет","Суб"],dayNamesMin:["Не","По","Ут","Ср","Че","Пе","Су"],weekHeader:"Сед",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("sr",{defaultButtonText:{month:"Месец",week:"Недеља",day:"Дан",list:"Планер"},allDayText:"Цео дан",eventLimitText:function(e){return"+ још "+e}})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){(t.defineLocale||t.lang).call(t,"th",{months:"มกราคม_กุมภาพันธ์_มีนาคม_เมษายน_พฤษภาคม_มิถุนายน_กรกฎาคม_สิงหาคม_กันยายน_ตุลาคม_พฤศจิกายน_ธันวาคม".split("_"),monthsShort:"มกรา_กุมภา_มีนา_เมษา_พฤษภา_มิถุนา_กรกฎา_สิงหา_กันยา_ตุลา_พฤศจิกา_ธันวา".split("_"),weekdays:"อาทิตย์_จันทร์_อังคาร_พุธ_พฤหัสบดี_ศุกร์_เสาร์".split("_"),weekdaysShort:"อาทิตย์_จันทร์_อังคาร_พุธ_พฤหัส_ศุกร์_เสาร์".split("_"),weekdaysMin:"อา._จ._อ._พ._พฤ._ศ._ส.".split("_"),longDateFormat:{LT:"H นาฬิกา m นาที",LTS:"LT s วินาที",L:"YYYY/MM/DD",LL:"D MMMM YYYY",LLL:"D MMMM YYYY เวลา LT",LLLL:"วันddddที่ D MMMM YYYY เวลา LT"},meridiemParse:/ก่อนเที่ยง|หลังเที่ยง/,isPM:function(e){return"หลังเที่ยง"===e},meridiem:function(e){return 12>e?"ก่อนเที่ยง":"หลังเที่ยง"},calendar:{sameDay:"[วันนี้ เวลา] LT",nextDay:"[พรุ่งนี้ เวลา] LT",nextWeek:"dddd[หน้า เวลา] LT",lastDay:"[เมื่อวานนี้ เวลา] LT",lastWeek:"[วัน]dddd[ที่แล้ว เวลา] LT",sameElse:"L"},relativeTime:{future:"อีก %s",past:"%sที่แล้ว",s:"ไม่กี่วินาที",m:"1 นาที",mm:"%d นาที",h:"1 ชั่วโมง",hh:"%d ชั่วโมง",d:"1 วัน",dd:"%d วัน",M:"1 เดือน",MM:"%d เดือน",y:"1 ปี",yy:"%d ปี"}}),e.fullCalendar.datepickerLang("th","th",{closeText:"ปิด",prevText:"« ย้อน",nextText:"ถัดไป »",currentText:"วันนี้",monthNames:["มกราคม","กุมภาพันธ์","มีนาคม","เมษายน","พฤษภาคม","มิถุนายน","กรกฎาคม","สิงหาคม","กันยายน","ตุลาคม","พฤศจิกายน","ธันวาคม"],monthNamesShort:["ม.ค.","ก.พ.","มี.ค.","เม.ย.","พ.ค.","มิ.ย.","ก.ค.","ส.ค.","ก.ย.","ต.ค.","พ.ย.","ธ.ค."],dayNames:["อาทิตย์","จันทร์","อังคาร","พุธ","พฤหัสบดี","ศุกร์","เสาร์"],dayNamesShort:["อา.","จ.","อ.","พ.","พฤ.","ศ.","ส."],dayNamesMin:["อา.","จ.","อ.","พ.","พฤ.","ศ.","ส."],weekHeader:"Wk",dateFormat:"dd/mm/yy",firstDay:0,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("th",{defaultButtonText:{month:"เดือน",week:"สัปดาห์",day:"วัน",list:"แผนงาน"},allDayText:"ตลอดวัน",eventLimitText:"เพิ่มเติม"})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){var n={1:"'inci",5:"'inci",8:"'inci",70:"'inci",80:"'inci",2:"'nci",7:"'nci",20:"'nci",50:"'nci",3:"'üncü",4:"'üncü",100:"'üncü",6:"'ncı",9:"'uncu",10:"'uncu",30:"'uncu",60:"'ıncı",90:"'ıncı"};(t.defineLocale||t.lang).call(t,"tr",{months:"Ocak_Şubat_Mart_Nisan_Mayıs_Haziran_Temmuz_Ağustos_Eylül_Ekim_Kasım_Aralık".split("_"),monthsShort:"Oca_Şub_Mar_Nis_May_Haz_Tem_Ağu_Eyl_Eki_Kas_Ara".split("_"),weekdays:"Pazar_Pazartesi_Salı_Çarşamba_Perşembe_Cuma_Cumartesi".split("_"),weekdaysShort:"Paz_Pts_Sal_Çar_Per_Cum_Cts".split("_"),weekdaysMin:"Pz_Pt_Sa_Ça_Pe_Cu_Ct".split("_"),longDateFormat:{LT:"HH:mm",LTS:"LT:ss",L:"DD.MM.YYYY",LL:"D MMMM YYYY",LLL:"D MMMM YYYY LT",LLLL:"dddd, D MMMM YYYY LT"},calendar:{sameDay:"[bugün saat] LT",nextDay:"[yarın saat] LT",nextWeek:"[haftaya] dddd [saat] LT",lastDay:"[dün] LT",lastWeek:"[geçen hafta] dddd [saat] LT",sameElse:"L"},relativeTime:{future:"%s sonra",past:"%s önce",s:"birkaç saniye",m:"bir dakika",mm:"%d dakika",h:"bir saat",hh:"%d saat",d:"bir gün",dd:"%d gün",M:"bir ay",MM:"%d ay",y:"bir yıl",yy:"%d yıl"},ordinalParse:/\d{1,2}'(inci|nci|üncü|ncı|uncu|ıncı)/,ordinal:function(e){if(0===e)return e+"'ıncı";var t=e%10,a=e%100-t,r=e>=100?100:null;return e+(n[t]||n[a]||n[r])},week:{dow:1,doy:7}}),e.fullCalendar.datepickerLang("tr","tr",{closeText:"kapat",prevText:"<geri",nextText:"ileri>",currentText:"bugün",monthNames:["Ocak","Şubat","Mart","Nisan","Mayıs","Haziran","Temmuz","Ağustos","Eylül","Ekim","Kasım","Aralık"],monthNamesShort:["Oca","Şub","Mar","Nis","May","Haz","Tem","Ağu","Eyl","Eki","Kas","Ara"],dayNames:["Pazar","Pazartesi","Salı","Çarşamba","Perşembe","Cuma","Cumartesi"],dayNamesShort:["Pz","Pt","Sa","Ça","Pe","Cu","Ct"],dayNamesMin:["Pz","Pt","Sa","Ça","Pe","Cu","Ct"],weekHeader:"Hf",dateFormat:"dd.mm.yy",firstDay:1,isRTL:!1,showMonthAfterYear:!1,yearSuffix:""}),e.fullCalendar.lang("tr",{defaultButtonText:{next:"ileri",month:"Ay",week:"Hafta",day:"Gün",list:"Ajanda"},allDayText:"Tüm gün",eventLimitText:"daha fazla"})});(function(e){"function"==typeof define&&define.amd?define(["jquery","moment"],e):e(jQuery,moment)})(function(e,t){(t.defineLocale||t.lang).call(t,"zh-cn",{months:"一月_二月_三月_四月_五月_六月_七月_八月_九月_十月_十一月_十二月".split("_"),monthsShort:"1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月".split("_"),weekdays:"星期日_星期一_星期二_星期三_星期四_星期五_星期六".split("_"),weekdaysShort:"周日_周一_周二_周三_周四_周五_周六".split("_"),weekdaysMin:"日_一_二_三_四_五_六".split("_"),longDateFormat:{LT:"Ah点mm",LTS:"Ah点m分s秒",L:"YYYY-MM-DD",LL:"YYYY年MMMD日",LLL:"YYYY年MMMD日LT",LLLL:"YYYY年MMMD日ddddLT",l:"YYYY-MM-DD",ll:"YYYY年MMMD日",lll:"YYYY年MMMD日LT",llll:"YYYY年MMMD日ddddLT"},meridiemParse:/凌晨|早上|上午|中午|下午|晚上/,meridiemHour:function(e,t){return 12===e&&(e=0),"凌晨"===t||"早上"===t||"上午"===t?e:"下午"===t||"晚上"===t?e+12:e>=11?e:e+12},meridiem:function(e,t){var n=100*e+t;return 600>n?"凌晨":900>n?"早上":1130>n?"上午":1230>n?"中午":1800>n?"下午":"晚上"},calendar:{sameDay:function(){return 0===this.minutes()?"[今天]Ah[点整]":"[今天]LT"},nextDay:function(){return 0===this.minutes()?"[明天]Ah[点整]":"[明天]LT"},lastDay:function(){return 0===this.minutes()?"[昨天]Ah[点整]":"[昨天]LT"},nextWeek:function(){var e,n;return e=t().startOf("week"),n=this.unix()-e.unix()>=604800?"[下]":"[本]",0===this.minutes()?n+"dddAh点整":n+"dddAh点mm"},lastWeek:function(){var e,n;return e=t().startOf("week"),n=this.unix()= 86400) { @@ -281,6 +326,9 @@ else if (Kanboard.Exists("analytic-task-time-column")) { drawTaskTimeColumn(); } + else if (Kanboard.Exists("analytic-lead-cycle-time")) { + drawLeadAndCycleTime(); + } }); })(); diff --git a/composer.json b/composer.json index 10a06c09..bca866e7 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "eluceo/ical": "*", "erusev/parsedown" : "1.5.3", "fabiang/xmpp" : "0.6.1", - "fguillot/json-rpc" : "dev-master", + "fguillot/json-rpc" : "1.0.0", "fguillot/picodb" : "1.0.0", "fguillot/simpleLogger" : "0.0.2", "fguillot/simple-validator" : "0.0.3", diff --git a/composer.lock b/composer.lock index 6c43632f..bc5e8e60 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "1ef3f084b6c8651977b1bbc84d86cb69", + "hash": "0048471872ea99cd30c53c0398c7d9f2", "packages": [ { "name": "christian-riesen/base32", @@ -260,16 +260,16 @@ }, { "name": "fguillot/json-rpc", - "version": "dev-master", + "version": "v1.0.0", "source": { "type": "git", "url": "https://github.com/fguillot/JsonRPC.git", - "reference": "ac3ddcf8f74777d72b8044d6d128f41aabe292f2" + "reference": "5a11f1414780a200f09b78d20ab72b5cee4faa95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fguillot/JsonRPC/zipball/ac3ddcf8f74777d72b8044d6d128f41aabe292f2", - "reference": "ac3ddcf8f74777d72b8044d6d128f41aabe292f2", + "url": "https://api.github.com/repos/fguillot/JsonRPC/zipball/5a11f1414780a200f09b78d20ab72b5cee4faa95", + "reference": "5a11f1414780a200f09b78d20ab72b5cee4faa95", "shasum": "" }, "require": { @@ -292,7 +292,7 @@ ], "description": "Simple Json-RPC client/server library that just works", "homepage": "https://github.com/fguillot/JsonRPC", - "time": "2015-07-01 19:26:37" + "time": "2015-07-01 19:50:31" }, { "name": "fguillot/picodb", @@ -819,7 +819,6 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { - "fguillot/json-rpc": 20, "swiftmailer/swiftmailer": 0, "symfony/console": 0 }, diff --git a/docs/analytics.markdown b/docs/analytics.markdown index a6685917..fb863531 100644 --- a/docs/analytics.markdown +++ b/docs/analytics.markdown @@ -37,6 +37,6 @@ Kanboard use the complexity or story point to generate this diagram. Don't forget to run the daily job for stats calculation ------------------------------------------------------- -To generate accurate analytics data, you should run the daily cronjob **project daily summaries** just before midnight. +To generate accurate analytics data, you should run the daily cronjob **project daily statistics** just before midnight. -[Read the documentation about Kanboard CLI](http://kanboard.net/documentation/cli) +[Read the documentation about Kanboard CLI](cli.markdown) diff --git a/docs/cli.markdown b/docs/cli.markdown index 21183177..38cba496 100644 --- a/docs/cli.markdown +++ b/docs/cli.markdown @@ -13,36 +13,35 @@ Usage - Run the command `./kanboard` ```bash -$ ./kanboard Kanboard version master Usage: - command [options] [arguments] + command [options] [arguments] Options: - --help (-h) Display this help message - --quiet (-q) Do not output any message - --verbose (-v|vv|vvv) Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug - --version (-V) Display this application version - --ansi Force ANSI output - --no-ansi Disable ANSI output - --no-interaction (-n) Do not ask any interactive question + -h, --help Display this help message + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi Force ANSI output + --no-ansi Disable ANSI output + -n, --no-interaction Do not ask any interactive question + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands: - help Displays help for a command - list Lists commands -export - export:daily-project-summary Daily project summary CSV export (number of tasks per column and per day) - export:subtasks Subtasks CSV export - export:tasks Tasks CSV export - export:transitions Task transitions CSV export -locale - locale:compare Compare application translations with the fr_FR locale - locale:sync Synchronize all translations based on the fr_FR locale -notification - notification:overdue-tasks Send notifications for overdue tasks -projects - projects:daily-summary Calculate daily summary data for all projects + help Displays help for a command + list Lists commands + export + export:daily-project-column-stats Daily project column stats CSV export (number of tasks per column and per day) + export:subtasks Subtasks CSV export + export:tasks Tasks CSV export + export:transitions Task transitions CSV export + locale + locale:compare Compare application translations with the fr_FR locale + locale:sync Synchronize all translations based on the fr_FR locale + notification + notification:overdue-tasks Send notifications for overdue tasks + projects + projects:daily-stats Calculate daily statistics for all projects ``` Available commands @@ -97,13 +96,13 @@ Example: The exported data will be printed on the standard output: ```bash -./kanboard export:daily-project-summary +./kanboard export:daily-project-column-stats ``` Example: ```bash -./kanboard export:daily-project-summary 1 2014-10-01 2014-11-30 > /tmp/my_custom_export.csv +./kanboard export:daily-project-column-stats 1 2014-10-01 2014-11-30 > /tmp/my_custom_export.csv ``` ### Send notifications for overdue tasks @@ -133,12 +132,12 @@ Cronjob example: 0 8 * * * cd /path/to/kanboard && ./kanboard notification:overdue-tasks >/dev/null 2>&1 ``` -### Run daily project summaries calculation +### Run daily project stats calculation -You can add a background task that calculate the daily project summaries everyday: +You can add a background task to calculate the project statistics everyday: ```bash -$ ./kanboard projects:daily-summary +$ ./kanboard projects:daily-stats Run calculation for Project #0 Run calculation for Project #1 Run calculation for Project #10 diff --git a/kanboard b/kanboard index e7bbb197..4d2c9ab1 100755 --- a/kanboard +++ b/kanboard @@ -12,8 +12,8 @@ $application = new Application('Kanboard', APP_VERSION); $application->add(new Console\TaskOverdueNotification($container)); $application->add(new Console\SubtaskExport($container)); $application->add(new Console\TaskExport($container)); -$application->add(new Console\ProjectDailySummaryCalculation($container)); -$application->add(new Console\ProjectDailySummaryExport($container)); +$application->add(new Console\ProjectDailyStatsCalculation($container)); +$application->add(new Console\ProjectDailyColumnStatsExport($container)); $application->add(new Console\TransitionExport($container)); $application->add(new Console\LocaleSync($container)); $application->add(new Console\LocaleComparator($container)); diff --git a/scripts/create-sample-burndown.php b/scripts/create-sample-burndown.php index ae0b2627..188ad7d9 100755 --- a/scripts/create-sample-burndown.php +++ b/scripts/create-sample-burndown.php @@ -3,11 +3,11 @@ require __DIR__.'/../app/common.php'; -use Model\ProjectDailySummary; +use Model\ProjectDailyColumnStats; use Model\TaskCreation; use Model\TaskStatus; -$pds = new ProjectDailySummary($container); +$pds = new ProjectDailyColumnStats($container); $taskCreation = new TaskCreation($container); $taskStatus = new TaskStatus($container); diff --git a/scripts/create-sample-cfd.php b/scripts/create-sample-cfd.php index 73135f7a..e7ff1ec9 100755 --- a/scripts/create-sample-cfd.php +++ b/scripts/create-sample-cfd.php @@ -3,11 +3,11 @@ require __DIR__.'/../app/common.php'; -use Model\ProjectDailySummary; +use Model\ProjectDailyColumnStats; use Model\TaskCreation; use Model\TaskPosition; -$pds = new ProjectDailySummary($container); +$pds = new ProjectDailyColumnStats($container); $taskCreation = new TaskCreation($container); $taskPosition = new TaskPosition($container); diff --git a/scripts/create-sample-lead-time.php b/scripts/create-sample-lead-time.php new file mode 100755 index 00000000..d4d82006 --- /dev/null +++ b/scripts/create-sample-lead-time.php @@ -0,0 +1,70 @@ +#!/usr/bin/env php +create(array('name' => 'Test Lead/Cycle time')); + +$container['db']->table('tasks')->insert(array( + 'title' => 'Lead time = 4d | Cycle time = 3d', + 'date_creation' => strtotime('-7 days'), + 'date_started' => strtotime('-6 days'), + 'date_completed' => strtotime('-3 days'), + 'is_active' => 0, + 'project_id' => 1, + 'column_id' => 1, +)); + +$container['db']->table('tasks')->insert(array( + 'title' => 'Lead time = 1d | Cycle time = 1d', + 'date_creation' => strtotime('-7 days'), + 'date_started' => strtotime('-7 days'), + 'date_completed' => strtotime('-6 days'), + 'is_active' => 0, + 'project_id' => 1, + 'column_id' => 1, +)); + +$pds->updateTotals(1, date('Y-m-d', strtotime('-6 days'))); + +$container['db']->table('tasks')->insert(array( + 'title' => 'Lead time = 7d | Cycle time = 5d', + 'date_creation' => strtotime('-7 days'), + 'date_started' => strtotime('-5 days'), + 'date_completed' => strtotime('today'), + 'is_active' => 0, + 'project_id' => 1, + 'column_id' => 1, +)); + +$pds->updateTotals(1, date('Y-m-d', strtotime('-5 days'))); + +$container['db']->table('tasks')->insert(array( + 'title' => 'Lead time = 1d | Cycle time = 0', + 'date_creation' => strtotime('-3 days'), + 'date_started' => 0, + 'date_completed' => 0, + 'is_active' => 0, + 'project_id' => 1, + 'column_id' => 1, +)); + +$pds->updateTotals(1, date('Y-m-d', strtotime('-4 days'))); + +$container['db']->table('tasks')->insert(array( + 'title' => 'Lead time = 1d | Cycle time = 1d', + 'date_creation' => strtotime('-3 days'), + 'date_started' => strtotime('-3 days'), + 'date_completed' => 0, + 'is_active' => 0, + 'project_id' => 1, + 'column_id' => 1, +)); + +$pds->updateTotals(1, date('Y-m-d', strtotime('-3 days'))); diff --git a/tests/units/ProjectDailyColumnStatsTest.php b/tests/units/ProjectDailyColumnStatsTest.php new file mode 100644 index 00000000..d314ac93 --- /dev/null +++ b/tests/units/ProjectDailyColumnStatsTest.php @@ -0,0 +1,90 @@ +container); + $pds = new ProjectDailyColumnStats($this->container); + $tc = new TaskCreation($this->container); + $ts = new TaskStatus($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'UnitTest'))); + $this->assertEquals(0, $pds->countDays(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d'))); + + for ($i = 0; $i < 10; $i++) { + $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 1))); + } + + for ($i = 0; $i < 5; $i++) { + $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 4))); + } + + $pds->updateTotals(1, date('Y-m-d', strtotime('-2days'))); + + for ($i = 0; $i < 15; $i++) { + $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 3))); + } + + for ($i = 0; $i < 25; $i++) { + $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 2))); + } + + $pds->updateTotals(1, date('Y-m-d', strtotime('-1 day'))); + + $this->assertNotFalse($ts->close(1)); + $this->assertNotFalse($ts->close(2)); + + for ($i = 0; $i < 3; $i++) { + $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 3))); + } + + for ($i = 0; $i < 5; $i++) { + $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 2))); + } + + for ($i = 0; $i < 4; $i++) { + $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 4))); + } + + $pds->updateTotals(1, date('Y-m-d')); + + $this->assertEquals(3, $pds->countDays(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d'))); + $metrics = $pds->getAggregatedMetrics(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d')); + + $this->assertNotEmpty($metrics); + $this->assertEquals(4, count($metrics)); + $this->assertEquals(5, count($metrics[0])); + $this->assertEquals('Date', $metrics[0][0]); + $this->assertEquals('Backlog', $metrics[0][1]); + $this->assertEquals('Ready', $metrics[0][2]); + $this->assertEquals('Work in progress', $metrics[0][3]); + $this->assertEquals('Done', $metrics[0][4]); + + $this->assertEquals(date('Y-m-d', strtotime('-2days')), $metrics[1][0]); + $this->assertEquals(10, $metrics[1][1]); + $this->assertEquals(0, $metrics[1][2]); + $this->assertEquals(0, $metrics[1][3]); + $this->assertEquals(5, $metrics[1][4]); + + $this->assertEquals(date('Y-m-d', strtotime('-1day')), $metrics[2][0]); + $this->assertEquals(10, $metrics[2][1]); + $this->assertEquals(25, $metrics[2][2]); + $this->assertEquals(15, $metrics[2][3]); + $this->assertEquals(5, $metrics[2][4]); + + $this->assertEquals(date('Y-m-d'), $metrics[3][0]); + $this->assertEquals(10, $metrics[3][1]); + $this->assertEquals(30, $metrics[3][2]); + $this->assertEquals(18, $metrics[3][3]); + $this->assertEquals(9, $metrics[3][4]); + } +} diff --git a/tests/units/ProjectDailySummaryTest.php b/tests/units/ProjectDailySummaryTest.php deleted file mode 100644 index ed806361..00000000 --- a/tests/units/ProjectDailySummaryTest.php +++ /dev/null @@ -1,90 +0,0 @@ -container); - $pds = new ProjectDailySummary($this->container); - $tc = new TaskCreation($this->container); - $ts = new TaskStatus($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'UnitTest'))); - $this->assertEquals(0, $pds->countDays(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d'))); - - for ($i = 0; $i < 10; $i++) { - $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 1))); - } - - for ($i = 0; $i < 5; $i++) { - $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 4))); - } - - $pds->updateTotals(1, date('Y-m-d', strtotime('-2days'))); - - for ($i = 0; $i < 15; $i++) { - $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 3))); - } - - for ($i = 0; $i < 25; $i++) { - $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 2))); - } - - $pds->updateTotals(1, date('Y-m-d', strtotime('-1 day'))); - - $this->assertNotFalse($ts->close(1)); - $this->assertNotFalse($ts->close(2)); - - for ($i = 0; $i < 3; $i++) { - $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 3))); - } - - for ($i = 0; $i < 5; $i++) { - $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 2))); - } - - for ($i = 0; $i < 4; $i++) { - $this->assertNotFalse($tc->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 4))); - } - - $pds->updateTotals(1, date('Y-m-d')); - - $this->assertEquals(3, $pds->countDays(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d'))); - $metrics = $pds->getAggregatedMetrics(1, date('Y-m-d', strtotime('-2days')), date('Y-m-d')); - - $this->assertNotEmpty($metrics); - $this->assertEquals(4, count($metrics)); - $this->assertEquals(5, count($metrics[0])); - $this->assertEquals('Date', $metrics[0][0]); - $this->assertEquals('Backlog', $metrics[0][1]); - $this->assertEquals('Ready', $metrics[0][2]); - $this->assertEquals('Work in progress', $metrics[0][3]); - $this->assertEquals('Done', $metrics[0][4]); - - $this->assertEquals(date('Y-m-d', strtotime('-2days')), $metrics[1][0]); - $this->assertEquals(10, $metrics[1][1]); - $this->assertEquals(0, $metrics[1][2]); - $this->assertEquals(0, $metrics[1][3]); - $this->assertEquals(5, $metrics[1][4]); - - $this->assertEquals(date('Y-m-d', strtotime('-1day')), $metrics[2][0]); - $this->assertEquals(10, $metrics[2][1]); - $this->assertEquals(25, $metrics[2][2]); - $this->assertEquals(15, $metrics[2][3]); - $this->assertEquals(5, $metrics[2][4]); - - $this->assertEquals(date('Y-m-d'), $metrics[3][0]); - $this->assertEquals(10, $metrics[3][1]); - $this->assertEquals(30, $metrics[3][2]); - $this->assertEquals(18, $metrics[3][3]); - $this->assertEquals(9, $metrics[3][4]); - } -} -- cgit v1.2.3