summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/Controller/Analytic.php44
-rw-r--r--app/Controller/Base.php5
-rw-r--r--app/Event/ProjectDailySummaryListener.php28
-rw-r--r--app/Locale/da_DK/translations.php2
-rw-r--r--app/Locale/de_DE/translations.php2
-rw-r--r--app/Locale/es_ES/translations.php2
-rw-r--r--app/Locale/fi_FI/translations.php2
-rw-r--r--app/Locale/fr_FR/translations.php4
-rw-r--r--app/Locale/it_IT/translations.php2
-rw-r--r--app/Locale/ja_JP/translations.php2
-rw-r--r--app/Locale/pl_PL/translations.php2
-rw-r--r--app/Locale/pt_BR/translations.php2
-rw-r--r--app/Locale/ru_RU/translations.php2
-rw-r--r--app/Locale/sv_SE/translations.php2
-rw-r--r--app/Locale/th_TH/translations.php2
-rw-r--r--app/Locale/zh_CN/translations.php2
-rw-r--r--app/Model/ProjectDailySummary.php181
-rw-r--r--app/Model/Task.php5
-rw-r--r--app/Schema/Mysql.php20
-rw-r--r--app/Schema/Postgres.php19
-rw-r--r--app/Schema/Sqlite.php19
-rw-r--r--app/Template/analytic/cfd.php26
-rw-r--r--app/Template/analytic/sidebar.php3
-rw-r--r--assets/css/app.css14
-rw-r--r--assets/css/base.css9
-rw-r--r--assets/css/form.css4
-rw-r--r--assets/js/analytic.js53
-rw-r--r--assets/js/app.js53
-rwxr-xr-xscripts/create-sample-cfd.php61
-rw-r--r--tests/units/ProjectDailySummary.php83
30 files changed, 643 insertions, 12 deletions
diff --git a/app/Controller/Analytic.php b/app/Controller/Analytic.php
index 7d112e6a..6c49089b 100644
--- a/app/Controller/Analytic.php
+++ b/app/Controller/Analytic.php
@@ -81,4 +81,48 @@ class Analytic extends Base
)));
}
}
+
+ /**
+ * Show cumulative flow diagram
+ *
+ * @access public
+ */
+ public function cfd()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues();
+
+ $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'];
+ }
+
+ if ($this->request->isAjax()) {
+ $this->response->json(array(
+ 'columns' => array_values($this->board->getColumnsList($project['id'])),
+ 'metrics' => $this->projectDailySummary->getRawMetrics($project['id'], $from, $to),
+ 'labels' => array(
+ 'column' => t('Column'),
+ 'day' => t('Date'),
+ 'total' => t('Tasks'),
+ )
+ ));
+ }
+ else {
+ $this->response->html($this->layout('analytic/cfd', array(
+ 'values' => array(
+ 'from' => $from,
+ 'to' => $to,
+ ),
+ 'display_graph' => $this->projectDailySummary->countDays($project['id'], $from, $to) >= 2,
+ 'project' => $project,
+ 'date_format' => $this->config->get('application_date_format'),
+ 'date_formats' => $this->dateParser->getAvailableFormats(),
+ 'title' => t('Cumulative flow diagram for "%s"', $project['name']),
+ )));
+ }
+ }
}
diff --git a/app/Controller/Base.php b/app/Controller/Base.php
index 5784605c..bd7bd2ee 100644
--- a/app/Controller/Base.php
+++ b/app/Controller/Base.php
@@ -108,7 +108,9 @@ abstract class Base
*/
public function __destruct()
{
- // $this->container['logger']->addDebug(var_export($this->container['db']->getLogMessages(), true));
+ // foreach ($this->container['db']->getLogMessages() as $message) {
+ // $this->container['logger']->addDebug($message);
+ // }
}
/**
@@ -173,6 +175,7 @@ abstract class Base
{
$models = array(
'projectActivity', // Order is important
+ 'projectDailySummary',
'action',
'project',
'webhook',
diff --git a/app/Event/ProjectDailySummaryListener.php b/app/Event/ProjectDailySummaryListener.php
new file mode 100644
index 00000000..cd593abc
--- /dev/null
+++ b/app/Event/ProjectDailySummaryListener.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Event;
+
+/**
+ * Project daily summary listener
+ *
+ * @package event
+ * @author Frederic Guillot
+ */
+class ProjectDailySummaryListener extends Base
+{
+ /**
+ * Execute the action
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action was executed or false when not executed
+ */
+ public function execute(array $data)
+ {
+ if (isset($data['project_id'])) {
+ return $this->projectDailySummary->updateTotals($data['project_id'], date('Y-m-d'));
+ }
+
+ return false;
+ }
+}
diff --git a/app/Locale/da_DK/translations.php b/app/Locale/da_DK/translations.php
index bc002699..34533e94 100644
--- a/app/Locale/da_DK/translations.php
+++ b/app/Locale/da_DK/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/de_DE/translations.php b/app/Locale/de_DE/translations.php
index b2b02477..bce2c233 100644
--- a/app/Locale/de_DE/translations.php
+++ b/app/Locale/de_DE/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php
index a54ccbfb..e26be0a1 100644
--- a/app/Locale/es_ES/translations.php
+++ b/app/Locale/es_ES/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/fi_FI/translations.php b/app/Locale/fi_FI/translations.php
index 5ac8a047..7af506a5 100644
--- a/app/Locale/fi_FI/translations.php
+++ b/app/Locale/fi_FI/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php
index 4c181917..ace478e2 100644
--- a/app/Locale/fr_FR/translations.php
+++ b/app/Locale/fr_FR/translations.php
@@ -579,7 +579,7 @@ return array(
'Column removed successfully.' => 'Colonne supprimée avec succès.',
'Edit Project' => 'Modifier le projet',
'Github Issue' => 'Ticket Github',
- 'Not enough data to show the graph.' => 'Pas assez de données pour afficher le graphique',
+ 'Not enough data to show the graph.' => 'Pas assez de données pour afficher le graphique.',
'Previous' => 'Précédent',
'The id must be an integer' => 'L\'id doit être un entier',
'The project id must be an integer' => 'L\'id du projet doit être un entier',
@@ -592,4 +592,6 @@ return array(
'This value is required' => 'Cette valeur est obligatoire',
'This value must be numeric' => 'Cette valeur doit être numérique',
'Unable to create this task.' => 'Impossible de créer cette tâche',
+ 'Cumulative flow diagram' => 'Diagramme de flux cumulé',
+ 'Cumulative flow diagram for "%s"' => 'Diagramme de flux cumulé pour « %s »',
);
diff --git a/app/Locale/it_IT/translations.php b/app/Locale/it_IT/translations.php
index 7b042e3b..6630f7f7 100644
--- a/app/Locale/it_IT/translations.php
+++ b/app/Locale/it_IT/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/ja_JP/translations.php b/app/Locale/ja_JP/translations.php
index 2022a184..0312a402 100644
--- a/app/Locale/ja_JP/translations.php
+++ b/app/Locale/ja_JP/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/pl_PL/translations.php b/app/Locale/pl_PL/translations.php
index 26982848..f48f0615 100644
--- a/app/Locale/pl_PL/translations.php
+++ b/app/Locale/pl_PL/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/pt_BR/translations.php b/app/Locale/pt_BR/translations.php
index 75f39c09..647348b9 100644
--- a/app/Locale/pt_BR/translations.php
+++ b/app/Locale/pt_BR/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php
index a120d2a9..1d220902 100644
--- a/app/Locale/ru_RU/translations.php
+++ b/app/Locale/ru_RU/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php
index 93ada787..ca79904a 100644
--- a/app/Locale/sv_SE/translations.php
+++ b/app/Locale/sv_SE/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php
index b06f2ac1..f5423c8e 100644
--- a/app/Locale/th_TH/translations.php
+++ b/app/Locale/th_TH/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Locale/zh_CN/translations.php b/app/Locale/zh_CN/translations.php
index a95ac1bc..1da74b13 100644
--- a/app/Locale/zh_CN/translations.php
+++ b/app/Locale/zh_CN/translations.php
@@ -592,4 +592,6 @@ return array(
// 'This value is required' => '',
// 'This value must be numeric' => '',
// 'Unable to create this task.' => '',
+ // 'Cumulative flow diagram' => '',
+ // 'Cumulative flow diagram for "%s"' => '',
);
diff --git a/app/Model/ProjectDailySummary.php b/app/Model/ProjectDailySummary.php
new file mode 100644
index 00000000..25a58368
--- /dev/null
+++ b/app/Model/ProjectDailySummary.php
@@ -0,0 +1,181 @@
+<?php
+
+namespace Model;
+
+use Core\Template;
+use Event\ProjectDailySummaryListener;
+
+/**
+ * Project daily summary
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class ProjectDailySummary extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'project_daily_summaries';
+
+ /**
+ * Update daily totals for the project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param string $date Record date (YYYY-MM-DD)
+ * @return boolean
+ */
+ public function updateTotals($project_id, $date)
+ {
+ return $this->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,
+ ));
+
+ $db->table(ProjectDailySummary::TABLE)
+ ->eq('project_id', $project_id)
+ ->eq('column_id', $column_id)
+ ->eq('day', $date)
+ ->update(array(
+ 'total' => $db->table(Task::TABLE)
+ ->eq('project_id', $project_id)
+ ->eq('column_id', $column_id)
+ ->eq('is_active', Task::STATUS_OPEN)
+ ->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 <= ?',
+ array($from, $to)
+ );
+
+ return $rq->fetchColumn(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',
+ 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)
+ ->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
+ * @return array
+ */
+ public function getAggregatedMetrics($project_id, $from, $to)
+ {
+ $columns = $this->board->getColumnsList($project_id);
+ $column_ids = array_keys($columns);
+ $metrics = array(array(e('Date')) + $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['total'];
+ }
+
+ // 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;
+ }
+
+ /**
+ * Attach events to be able to record the metrics
+ *
+ * @access public
+ */
+ public function attachEvents()
+ {
+ $events = array(
+ Task::EVENT_CREATE,
+ Task::EVENT_CLOSE,
+ Task::EVENT_OPEN,
+ Task::EVENT_MOVE_COLUMN,
+ );
+
+ $listener = new ProjectDailySummaryListener($this->container);
+
+ foreach ($events as $event_name) {
+ $this->event->attach($event_name, $listener);
+ }
+ }
+}
diff --git a/app/Model/Task.php b/app/Model/Task.php
index a0090641..25a4f000 100644
--- a/app/Model/Task.php
+++ b/app/Model/Task.php
@@ -309,8 +309,6 @@ class Task extends Base
*/
private function savePositions($moved_task_id, array $columns)
{
- $this->db->startTransaction();
-
foreach ($columns as $column_id => $column) {
$position = 1;
@@ -336,14 +334,11 @@ class Task extends Base
$position++;
if (! $result) {
- $this->db->cancelTransaction();
return false;
}
}
}
- $this->db->closeTransaction();
-
return true;
}
diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php
index 4f74f761..e73f6cf4 100644
--- a/app/Schema/Mysql.php
+++ b/app/Schema/Mysql.php
@@ -5,7 +5,25 @@ namespace Schema;
use PDO;
use Core\Security;
-const VERSION = 34;
+const VERSION = 35;
+
+function version_35($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE project_daily_summaries (
+ id INT NOT NULL AUTO_INCREMENT,
+ day CHAR(10) NOT NULL,
+ project_id INT NOT NULL,
+ column_id INT NOT NULL,
+ total INT NOT NULL DEFAULT 0,
+ PRIMARY KEY(id),
+ FOREIGN KEY(column_id) REFERENCES columns(id) ON DELETE CASCADE,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec('CREATE UNIQUE INDEX project_daily_column_stats_idx ON project_daily_summaries(day, project_id, column_id)');
+}
function version_34($pdo)
{
diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php
index f301f3e8..4ec3885c 100644
--- a/app/Schema/Postgres.php
+++ b/app/Schema/Postgres.php
@@ -5,7 +5,24 @@ namespace Schema;
use PDO;
use Core\Security;
-const VERSION = 15;
+const VERSION = 16;
+
+function version_16($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE project_daily_summaries (
+ id SERIAL PRIMARY KEY,
+ day CHAR(10) NOT NULL,
+ project_id INTEGER NOT NULL,
+ column_id INTEGER NOT NULL,
+ total INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY(column_id) REFERENCES columns(id) ON DELETE CASCADE,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ )
+ ");
+
+ $pdo->exec('CREATE UNIQUE INDEX project_daily_column_stats_idx ON project_daily_summaries(day, project_id, column_id)');
+}
function version_15($pdo)
{
diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php
index 8571d924..cdc74465 100644
--- a/app/Schema/Sqlite.php
+++ b/app/Schema/Sqlite.php
@@ -5,7 +5,24 @@ namespace Schema;
use Core\Security;
use PDO;
-const VERSION = 34;
+const VERSION = 35;
+
+function version_35($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE project_daily_summaries (
+ id INTEGER PRIMARY KEY,
+ day TEXT NOT NULL,
+ project_id INTEGER NOT NULL,
+ column_id INTEGER NOT NULL,
+ total INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY(column_id) REFERENCES columns(id) ON DELETE CASCADE,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ )
+ ");
+
+ $pdo->exec('CREATE UNIQUE INDEX project_daily_column_stats_idx ON project_daily_summaries(day, project_id, column_id)');
+}
function version_34($pdo)
{
diff --git a/app/Template/analytic/cfd.php b/app/Template/analytic/cfd.php
new file mode 100644
index 00000000..46cee47e
--- /dev/null
+++ b/app/Template/analytic/cfd.php
@@ -0,0 +1,26 @@
+<div class="page-header">
+ <h2><?= t('Cumulative flow diagram') ?></h2>
+</div>
+
+<?php if (! $display_graph): ?>
+ <p class="alert"><?= t('Not enough data to show the graph.') ?></p>
+<?php else: ?>
+ <section id="analytic-cfd">
+ <div id="chart" data-url="<?= Helper\u('analytic', 'cfd', array('project_id' => $project['id'], 'from' => $values['from'], 'to' => $values['to'])) ?>"></div>
+ </section>
+<?php endif ?>
+
+<hr/>
+
+<form method="post" class="form-inline" action="<?= Helper\u('analytic', 'cfd', array('project_id' => $project['id'])) ?>" autocomplete="off">
+
+ <?= Helper\form_csrf() ?>
+
+ <?= Helper\form_label(t('Start Date'), 'from') ?>
+ <?= Helper\form_text('from', $values, array(), array('required', 'placeholder="'.Helper\in_list($date_format, $date_formats).'"'), 'form-date') ?>
+
+ <?= Helper\form_label(t('End Date'), 'to') ?>
+ <?= Helper\form_text('to', $values, array(), array('required', 'placeholder="'.Helper\in_list($date_format, $date_formats).'"'), 'form-date') ?>
+
+ <input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue"/>
+</form>
diff --git a/app/Template/analytic/sidebar.php b/app/Template/analytic/sidebar.php
index dded245a..9b2fd9f6 100644
--- a/app/Template/analytic/sidebar.php
+++ b/app/Template/analytic/sidebar.php
@@ -7,5 +7,8 @@
<li>
<?= Helper\a(t('User repartition'), 'analytic', 'users', array('project_id' => $project['id'])) ?>
</li>
+ <li>
+ <?= Helper\a(t('Cumulative flow diagram'), 'analytic', 'cfd', array('project_id' => $project['id'])) ?>
+ </li>
</ul>
</div> \ No newline at end of file
diff --git a/assets/css/app.css b/assets/css/app.css
index e97a68c2..3841e4bb 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -33,7 +33,15 @@ ul.no-bullet li {
.pull-right {
text-align: right;
-}/* links */
+}
+
+hr {
+ border: 0;
+ height: 0;
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.3);
+}
+/* links */
a {
color: #3366CC;
border: none;
@@ -280,6 +288,10 @@ ul.form-errors li {
margin-right: 15px;
}
+.form-inline .form-required {
+ display: none;
+}
+
input.form-date {
width: 150px;
}
diff --git a/assets/css/base.css b/assets/css/base.css
index 4a80d5b6..f4c438a2 100644
--- a/assets/css/base.css
+++ b/assets/css/base.css
@@ -32,4 +32,11 @@ ul.no-bullet li {
.pull-right {
text-align: right;
-} \ No newline at end of file
+}
+
+hr {
+ border: 0;
+ height: 0;
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.3);
+}
diff --git a/assets/css/form.css b/assets/css/form.css
index d6336e26..465cffe0 100644
--- a/assets/css/form.css
+++ b/assets/css/form.css
@@ -121,6 +121,10 @@ ul.form-errors li {
margin-right: 15px;
}
+.form-inline .form-required {
+ display: none;
+}
+
input.form-date {
width: 150px;
}
diff --git a/assets/js/analytic.js b/assets/js/analytic.js
index 22fb672e..e8cffdc7 100644
--- a/assets/js/analytic.js
+++ b/assets/js/analytic.js
@@ -10,7 +10,60 @@ Kanboard.Analytic = (function() {
else if (Kanboard.Exists("analytic-user-repartition")) {
Kanboard.Analytic.UserRepartition.Init();
}
+ else if (Kanboard.Exists("analytic-cfd")) {
+ Kanboard.Analytic.CFD.Init();
+ }
+ }
+ };
+
+})();
+
+Kanboard.Analytic.CFD = (function() {
+
+ function fetchData()
+ {
+ jQuery.getJSON($("#chart").attr("data-url"), function(data) {
+ drawGraph(data.metrics, data.labels, data.columns);
+ });
+ }
+
+ function drawGraph(metrics, labels, columns)
+ {
+ var series = prepareSeries(metrics, labels);
+
+ var svg = dimple.newSvg("#chart", 800, 380);
+ var chart = new dimple.chart(svg, series);
+
+ var x = chart.addCategoryAxis("x", labels['day']);
+ x.addOrderRule("Date");
+
+ chart.addMeasureAxis("y", labels['total']);
+
+ var s = chart.addSeries(labels['column'], dimple.plot.area);
+ s.addOrderRule(columns.reverse());
+
+ chart.addLegend(10, 10, 500, 30, "left");
+ chart.draw();
+ }
+
+ function prepareSeries(metrics, labels)
+ {
+ var series = [];
+
+ for (var i = 0; i < metrics.length; i++) {
+
+ var row = {};
+ row[labels['column']] = metrics[i]['column_title'];
+ row[labels['day']] = metrics[i]['day'];
+ row[labels['total']] = metrics[i]['total'];
+ series.push(row);
}
+
+ return series;
+ }
+
+ return {
+ Init: fetchData
};
})();
diff --git a/assets/js/app.js b/assets/js/app.js
index ed017b0b..1342e090 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -279,7 +279,60 @@ Kanboard.Analytic = (function() {
else if (Kanboard.Exists("analytic-user-repartition")) {
Kanboard.Analytic.UserRepartition.Init();
}
+ else if (Kanboard.Exists("analytic-cfd")) {
+ Kanboard.Analytic.CFD.Init();
+ }
+ }
+ };
+
+})();
+
+Kanboard.Analytic.CFD = (function() {
+
+ function fetchData()
+ {
+ jQuery.getJSON($("#chart").attr("data-url"), function(data) {
+ drawGraph(data.metrics, data.labels, data.columns);
+ });
+ }
+
+ function drawGraph(metrics, labels, columns)
+ {
+ var series = prepareSeries(metrics, labels);
+
+ var svg = dimple.newSvg("#chart", 800, 380);
+ var chart = new dimple.chart(svg, series);
+
+ var x = chart.addCategoryAxis("x", labels['day']);
+ x.addOrderRule("Date");
+
+ chart.addMeasureAxis("y", labels['total']);
+
+ var s = chart.addSeries(labels['column'], dimple.plot.area);
+ s.addOrderRule(columns.reverse());
+
+ chart.addLegend(10, 10, 500, 30, "left");
+ chart.draw();
+ }
+
+ function prepareSeries(metrics, labels)
+ {
+ var series = [];
+
+ for (var i = 0; i < metrics.length; i++) {
+
+ var row = {};
+ row[labels['column']] = metrics[i]['column_title'];
+ row[labels['day']] = metrics[i]['day'];
+ row[labels['total']] = metrics[i]['total'];
+ series.push(row);
}
+
+ return series;
+ }
+
+ return {
+ Init: fetchData
};
})();
diff --git a/scripts/create-sample-cfd.php b/scripts/create-sample-cfd.php
new file mode 100755
index 00000000..5319a1ec
--- /dev/null
+++ b/scripts/create-sample-cfd.php
@@ -0,0 +1,61 @@
+#!/usr/bin/env php
+<?php
+
+require __DIR__.'/../app/common.php';
+
+use Model\ProjectDailySummary;
+use Model\Task;
+
+$pds = new ProjectDailySummary($container);
+$taskModel = new Task($container);
+
+for ($i = 1; $i <= 15; $i++) {
+
+ $task = array(
+ 'title' => 'Task #'.$i,
+ 'project_id' => 1,
+ 'column_id' => 1,
+ );
+
+ $taskModel->create($task);
+}
+
+$pds->updateTotals(1, date('Y-m-d', strtotime('-7 days')));
+
+$taskModel->movePosition(1, 1, 2, 1);
+$taskModel->movePosition(1, 2, 2, 1);
+$taskModel->movePosition(1, 3, 2, 1);
+$pds->updateTotals(1, date('Y-m-d', strtotime('-6 days')));
+
+$taskModel->movePosition(1, 3, 3, 1);
+$taskModel->movePosition(1, 4, 3, 1);
+$taskModel->movePosition(1, 5, 3, 1);
+$pds->updateTotals(1, date('Y-m-d', strtotime('-5 days')));
+
+$taskModel->movePosition(1, 5, 4, 1);
+$taskModel->movePosition(1, 6, 4, 1);
+$pds->updateTotals(1, date('Y-m-d', strtotime('-4 days')));
+
+$taskModel->movePosition(1, 7, 4, 1);
+$taskModel->movePosition(1, 8, 4, 1);
+$pds->updateTotals(1, date('Y-m-d', strtotime('-3 days')));
+
+$taskModel->movePosition(1, 9, 3, 1);
+$taskModel->movePosition(1, 10, 2, 1);
+$pds->updateTotals(1, date('Y-m-d', strtotime('-2 days')));
+
+$taskModel->create(array('title' => 'Random task', 'project_id' => 1));
+$taskModel->movePosition(1, 11, 2, 1);
+$taskModel->movePosition(1, 12, 4, 1);
+$taskModel->movePosition(1, 13, 4, 1);
+$pds->updateTotals(1, date('Y-m-d', strtotime('-2 days')));
+
+$taskModel->movePosition(1, 14, 3, 1);
+$pds->updateTotals(1, date('Y-m-d', strtotime('-1 days')));
+
+$taskModel->movePosition(1, 15, 4, 1);
+$taskModel->movePosition(1, 16, 4, 1);
+
+$taskModel->create(array('title' => 'Random task', 'project_id' => 1));
+
+$pds->updateTotals(1, date('Y-m-d')); \ No newline at end of file
diff --git a/tests/units/ProjectDailySummary.php b/tests/units/ProjectDailySummary.php
new file mode 100644
index 00000000..88a0e05c
--- /dev/null
+++ b/tests/units/ProjectDailySummary.php
@@ -0,0 +1,83 @@
+<?php
+
+require_once __DIR__.'/Base.php';
+
+use Model\Project;
+use Model\ProjectDailySummary;
+use Model\Task;
+
+class ProjectDailySummaryTest extends Base
+{
+ public function testUpdateTotals()
+ {
+ $p = new Project($this->container);
+ $pds = new ProjectDailySummary($this->container);
+ $t = new Task($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($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 1)));
+ }
+
+ for ($i = 0; $i < 5; $i++) {
+ $this->assertNotFalse($t->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($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 3)));
+ }
+
+ for ($i = 0; $i < 25; $i++) {
+ $this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 2)));
+ }
+
+ $pds->updateTotals(1, date('Y-m-d', strtotime('-1 day')));
+
+ $this->assertNotFalse($t->close(1));
+ $this->assertNotFalse($t->close(2));
+
+ for ($i = 0; $i < 3; $i++) {
+ $this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 3)));
+ }
+
+ for ($i = 0; $i < 5; $i++) {
+ $this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 2)));
+ }
+
+ for ($i = 0; $i < 4; $i++) {
+ $this->assertNotFalse($t->create(array('title' => 'Task #'.$i, 'project_id' => 1, 'column_id' => 4)));
+ }
+
+ $pds->updateTotals(1, date('Y-m-d'));
+
+ $this->assertEquals(3, $pds->countDays(3, 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('Backlog', $metrics[0][1]);
+
+ $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(8, $metrics[3][1]);
+ $this->assertEquals(30, $metrics[3][2]);
+ $this->assertEquals(18, $metrics[3][3]);
+ $this->assertEquals(9, $metrics[3][4]);
+ }
+}