summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/Api/Project.php30
-rw-r--r--app/Api/Task.php26
-rw-r--r--app/Auth/RememberMe.php34
-rw-r--r--app/Console/Base.php21
-rw-r--r--app/Console/ProjectDailyColumnStatsExport.php (renamed from app/Console/ProjectDailySummaryExport.php)8
-rw-r--r--app/Console/ProjectDailyStatsCalculation.php (renamed from app/Console/ProjectDailySummaryCalculation.php)9
-rw-r--r--app/Controller/Activity.php16
-rw-r--r--app/Controller/Analytic.php58
-rw-r--r--app/Controller/Board.php24
-rw-r--r--app/Controller/Export.php2
-rw-r--r--app/Controller/Ical.php4
-rw-r--r--app/Controller/Task.php26
-rw-r--r--app/Core/Base.php3
-rw-r--r--app/Core/Helper.php34
-rw-r--r--app/Helper/Board.php24
-rw-r--r--app/Helper/Dt.php (renamed from app/Helper/Datetime.php)22
-rw-r--r--app/Helper/Url.php4
-rw-r--r--app/Integration/SlackWebhook.php24
-rw-r--r--app/Locale/sv_SE/translations.php382
-rw-r--r--app/Model/Authentication.php5
-rw-r--r--app/Model/DateParser.php51
-rw-r--r--app/Model/ProjectAnalytic.php94
-rw-r--r--app/Model/ProjectDailyColumnStats.php (renamed from app/Model/ProjectDailySummary.php)42
-rw-r--r--app/Model/ProjectDailyStats.php72
-rw-r--r--app/Model/TaskAnalytic.php71
-rw-r--r--app/Model/TaskCreation.php9
-rw-r--r--app/Model/TaskFilter.php24
-rw-r--r--app/Model/TaskLink.php1
-rw-r--r--app/Model/TaskModification.php3
-rw-r--r--app/Model/TaskPosition.php227
-rw-r--r--app/Model/TaskValidator.php2
-rw-r--r--app/Model/Transition.php16
-rw-r--r--app/Model/UserSession.php24
-rw-r--r--app/Schema/Mysql.php27
-rw-r--r--app/Schema/Postgres.php26
-rw-r--r--app/Schema/Sqlite.php26
-rw-r--r--app/ServiceProvider/ClassProvider.php4
-rw-r--r--app/Subscriber/ProjectDailySummarySubscriber.php3
-rw-r--r--app/Template/activity/task.php (renamed from app/Template/task/activity.php)0
-rw-r--r--app/Template/analytic/avg_time_columns.php29
-rw-r--r--app/Template/analytic/lead_cycle_time.php42
-rw-r--r--app/Template/analytic/sidebar.php6
-rw-r--r--app/Template/board/task_private.php88
-rw-r--r--app/Template/config/integrations.php2
-rw-r--r--app/Template/layout.php2
-rw-r--r--app/Template/project/filters.php13
-rw-r--r--app/Template/project/integrations.php2
-rw-r--r--app/Template/subtask/show.php2
-rw-r--r--app/Template/task/analytics.php36
-rw-r--r--app/Template/task/layout.php2
-rw-r--r--app/Template/task/sidebar.php5
-rw-r--r--app/Template/task/time.php7
-rw-r--r--app/Template/task/transitions.php2
-rw-r--r--app/Template/timetable_day/index.php4
-rw-r--r--app/Template/timetable_extra/index.php4
-rw-r--r--app/Template/timetable_off/index.php4
-rw-r--r--app/Template/timetable_week/index.php8
-rw-r--r--app/common.php2
58 files changed, 1257 insertions, 481 deletions
diff --git a/app/Api/Project.php b/app/Api/Project.php
index 2451cd9c..faf2a3da 100644
--- a/app/Api/Project.php
+++ b/app/Api/Project.php
@@ -12,17 +12,17 @@ class Project extends Base
{
public function getProjectById($project_id)
{
- return $this->project->getById($project_id);
+ return $this->formatProject($this->project->getById($project_id));
}
public function getProjectByName($name)
{
- return $this->project->getByName($name);
+ return $this->formatProject($this->project->getByName($name));
}
public function getAllProjects()
{
- return $this->project->getAll();
+ return $this->formatProjects($this->project->getAll());
}
public function removeProject($project_id)
@@ -82,4 +82,28 @@ class Project extends Base
list($valid,) = $this->project->validateModification($values);
return $valid && $this->project->update($values);
}
+
+ private function formatProject($project)
+ {
+ if (! empty($project)) {
+ $project['url'] = array(
+ 'board' => $this->helper->url->base().$this->helper->url->to('board', 'show', array('project_id' => $project['id'])),
+ 'calendar' => $this->helper->url->base().$this->helper->url->to('calendar', 'show', array('project_id' => $project['id'])),
+ 'list' => $this->helper->url->base().$this->helper->url->to('listing', 'show', array('project_id' => $project['id'])),
+ );
+ }
+
+ return $project;
+ }
+
+ private function formatProjects($projects)
+ {
+ if (! empty($projects)) {
+ foreach ($projects as &$project) {
+ $project = $this->formatProject($project);
+ }
+ }
+
+ return $projects;
+ }
}
diff --git a/app/Api/Task.php b/app/Api/Task.php
index e06c012b..ade49a6d 100644
--- a/app/Api/Task.php
+++ b/app/Api/Task.php
@@ -14,17 +14,17 @@ class Task extends Base
{
public function getTask($task_id)
{
- return $this->taskFinder->getById($task_id);
+ return $this->formatTask($this->taskFinder->getById($task_id));
}
public function getTaskByReference($project_id, $reference)
{
- return $this->taskFinder->getByReference($project_id, $reference);
+ return $this->formatTask($this->taskFinder->getByReference($project_id, $reference));
}
public function getAllTasks($project_id, $status_id = TaskModel::STATUS_OPEN)
{
- return $this->taskFinder->getAll($project_id, $status_id);
+ return $this->formatTasks($this->taskFinder->getAll($project_id, $status_id));
}
public function getOverdueTasks()
@@ -115,4 +115,24 @@ class Task extends Base
list($valid) = $this->taskValidator->validateApiModification($values);
return $valid && $this->taskModification->update($values);
}
+
+ private function formatTask($task)
+ {
+ if (! empty($task)) {
+ $task['url'] = $this->helper->url->base().$this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']));
+ }
+
+ return $task;
+ }
+
+ private function formatTasks($tasks)
+ {
+ if (! empty($tasks)) {
+ foreach ($tasks as &$task) {
+ $task = $this->formatTask($task);
+ }
+ }
+
+ return $tasks;
+ }
}
diff --git a/app/Auth/RememberMe.php b/app/Auth/RememberMe.php
index e8b20f37..eebf4f4b 100644
--- a/app/Auth/RememberMe.php
+++ b/app/Auth/RememberMe.php
@@ -119,31 +119,6 @@ class RememberMe extends Base
}
/**
- * Update the database and the cookie with a new sequence
- *
- * @access public
- */
- public function refresh()
- {
- $credentials = $this->readCookie();
-
- if ($credentials !== false) {
-
- $record = $this->find($credentials['token'], $credentials['sequence']);
-
- if ($record) {
-
- // Update the sequence
- $this->writeCookie(
- $record['token'],
- $this->update($record['token']),
- $record['expiration']
- );
- }
- }
- }
-
- /**
* Remove a session record
*
* @access public
@@ -197,9 +172,10 @@ class RememberMe extends Base
$this->cleanup($user_id);
- $this->db
- ->table(self::TABLE)
- ->insert(array(
+ $this
+ ->db
+ ->table(self::TABLE)
+ ->insert(array(
'user_id' => $user_id,
'ip' => $ip,
'user_agent' => $user_agent,
@@ -207,7 +183,7 @@ class RememberMe extends Base
'sequence' => $sequence,
'expiration' => $expiration,
'date_creation' => time(),
- ));
+ ));
return array(
'token' => $token,
diff --git a/app/Console/Base.php b/app/Console/Base.php
index 07243080..86d78ab3 100644
--- a/app/Console/Base.php
+++ b/app/Console/Base.php
@@ -11,16 +11,17 @@ use Symfony\Component\Console\Command\Command;
* @package console
* @author Frederic Guillot
*
- * @property \Model\Notification $notification
- * @property \Model\Project $project
- * @property \Model\ProjectPermission $projectPermission
- * @property \Model\ProjectAnalytic $projectAnalytic
- * @property \Model\ProjectDailySummary $projectDailySummary
- * @property \Model\SubtaskExport $subtaskExport
- * @property \Model\Task $task
- * @property \Model\TaskExport $taskExport
- * @property \Model\TaskFinder $taskFinder
- * @property \Model\Transition $transition
+ * @property \Model\Notification $notification
+ * @property \Model\Project $project
+ * @property \Model\ProjectPermission $projectPermission
+ * @property \Model\ProjectAnalytic $projectAnalytic
+ * @property \Model\ProjectDailyColumnStats $projectDailyColumnStats
+ * @property \Model\ProjectDailyStats $projectDailyColumnStats
+ * @property \Model\SubtaskExport $subtaskExport
+ * @property \Model\Task $task
+ * @property \Model\TaskExport $taskExport
+ * @property \Model\TaskFinder $taskFinder
+ * @property \Model\Transition $transition
*/
abstract class Base extends Command
{
diff --git a/app/Console/ProjectDailySummaryExport.php b/app/Console/ProjectDailyColumnStatsExport.php
index 07841d52..b9830662 100644
--- a/app/Console/ProjectDailySummaryExport.php
+++ b/app/Console/ProjectDailyColumnStatsExport.php
@@ -7,13 +7,13 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-class ProjectDailySummaryExport extends Base
+class ProjectDailyColumnStatsExport extends Base
{
protected function configure()
{
$this
- ->setName('export:daily-project-summary')
- ->setDescription('Daily project summary CSV export (number of tasks per column and per day)')
+ ->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)');
@@ -21,7 +21,7 @@ class ProjectDailySummaryExport extends Base
protected function execute(InputInterface $input, OutputInterface $output)
{
- $data = $this->projectDailySummary->getAggregatedMetrics(
+ $data = $this->projectDailyColumnStats->getAggregatedMetrics(
$input->getArgument('project_id'),
$input->getArgument('start_date'),
$input->getArgument('end_date')
diff --git a/app/Console/ProjectDailySummaryCalculation.php b/app/Console/ProjectDailyStatsCalculation.php
index b2ada1b6..4b77c556 100644
--- a/app/Console/ProjectDailySummaryCalculation.php
+++ b/app/Console/ProjectDailyStatsCalculation.php
@@ -6,13 +6,13 @@ use Model\Project;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-class ProjectDailySummaryCalculation extends Base
+class ProjectDailyStatsCalculation extends Base
{
protected function configure()
{
$this
- ->setName('projects:daily-summary')
- ->setDescription('Calculate daily summary data for all projects');
+ ->setName('projects:daily-stats')
+ ->setDescription('Calculate daily statistics for all projects');
}
protected function execute(InputInterface $input, OutputInterface $output)
@@ -21,7 +21,8 @@ class ProjectDailySummaryCalculation extends Base
foreach ($projects as $project) {
$output->writeln('Run calculation for '.$project['name']);
- $this->projectDailySummary->updateTotals($project['id'], date('Y-m-d'));
+ $this->projectDailyColumnStats->updateTotals($project['id'], date('Y-m-d'));
+ $this->projectDailyStats->updateTotals($project['id'], date('Y-m-d'));
}
}
}
diff --git a/app/Controller/Activity.php b/app/Controller/Activity.php
index 2276b3b8..234e4be4 100644
--- a/app/Controller/Activity.php
+++ b/app/Controller/Activity.php
@@ -26,4 +26,20 @@ class Activity extends Base
'title' => t('%s\'s activity', $project['name'])
)));
}
+
+ /**
+ * Display task activities
+ *
+ * @access public
+ */
+ public function task()
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->taskLayout('activity/task', array(
+ 'title' => $task['title'],
+ 'task' => $task,
+ 'events' => $this->projectActivity->getTask($task['id']),
+ )));
+ }
}
diff --git a/app/Controller/Analytic.php b/app/Controller/Analytic.php
index 2413ba92..ca2146ed 100644
--- a/app/Controller/Analytic.php
+++ b/app/Controller/Analytic.php
@@ -3,7 +3,7 @@
namespace Controller;
/**
- * Project Anaytic controller
+ * Project Analytic controller
*
* @package controller
* @author Frederic Guillot
@@ -27,6 +27,56 @@ class Analytic extends Base
}
/**
+ * 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
+ *
+ * @access public
+ */
+ public function averageTimeByColumn()
+ {
+ $project = $this->getProject();
+
+ $this->response->html($this->layout('analytic/avg_time_columns', array(
+ 'project' => $project,
+ 'metrics' => $this->projectAnalytic->getAverageTimeSpentByColumn($project['id']),
+ 'title' => t('Average time spent into each column for "%s"', $project['name']),
+ )));
+ }
+
+ /**
* Show tasks distribution graph
*
* @access public
@@ -88,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'));
@@ -96,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(
@@ -104,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/Board.php b/app/Controller/Board.php
index caaa38ef..ac80a192 100644
--- a/app/Controller/Board.php
+++ b/app/Controller/Board.php
@@ -310,4 +310,28 @@ class Board extends Base
'recurrence_basedate_list' => $this->task->getRecurrenceBasedateList(),
)));
}
+
+ /**
+ * Enable collapsed mode
+ *
+ * @access public
+ */
+ public function collapse()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $this->userSession->setBoardDisplayMode($project_id, true);
+ $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $project_id)));
+ }
+
+ /**
+ * Enable expanded mode
+ *
+ * @access public
+ */
+ public function expand()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $this->userSession->setBoardDisplayMode($project_id, false);
+ $this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $project_id)));
+ }
}
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/Controller/Ical.php b/app/Controller/Ical.php
index 8a7ed8b5..0129915e 100644
--- a/app/Controller/Ical.php
+++ b/app/Controller/Ical.php
@@ -78,8 +78,8 @@ class Ical extends Base
*/
private function renderCalendar(TaskFilter $filter, iCalendar $calendar)
{
- $start = $this->request->getStringParam('start', strtotime('-1 month'));
- $end = $this->request->getStringParam('end', strtotime('+2 months'));
+ $start = $this->request->getStringParam('start', strtotime('-2 month'));
+ $end = $this->request->getStringParam('end', strtotime('+6 months'));
// Tasks
if ($this->config->get('calendar_project_tasks', 'date_started') === 'date_creation') {
diff --git a/app/Controller/Task.php b/app/Controller/Task.php
index dc83f7b1..0d85f411 100644
--- a/app/Controller/Task.php
+++ b/app/Controller/Task.php
@@ -64,7 +64,7 @@ class Task extends Base
'time_spent' => $task['time_spent'] ?: '',
);
- $this->dateParser->format($values, array('date_started'));
+ $this->dateParser->format($values, array('date_started'), 'Y-m-d H:i');
$this->response->html($this->taskLayout('task/show', array(
'project' => $this->project->getById($task['project_id']),
@@ -88,19 +88,20 @@ class Task extends Base
}
/**
- * Display task activities
+ * Display task analytics
*
* @access public
*/
- public function activites()
+ public function analytics()
{
$task = $this->getTask();
- $this->response->html($this->taskLayout('task/activity', array(
+ $this->response->html($this->taskLayout('task/analytics', array(
'title' => $task['title'],
'task' => $task,
- 'ajax' => $this->request->isAjax(),
- 'events' => $this->projectActivity->getTask($task['id']),
+ 'lead_time' => $this->taskAnalytic->getLeadTime($task),
+ 'cycle_time' => $this->taskAnalytic->getCycleTime($task),
+ 'time_spent_columns' => $this->taskAnalytic->getTimeSpentByColumn($task),
)));
}
@@ -151,7 +152,6 @@ class Task extends Base
{
$project = $this->getProject();
$values = $this->request->getValues();
- $values['creator_id'] = $this->userSession->getId();
list($valid, $errors) = $this->taskValidator->validateCreation($values);
@@ -618,4 +618,16 @@ class Task extends Base
'transitions' => $this->transition->getAllByTask($task['id']),
)));
}
+
+ /**
+ * Set automatically the start date
+ *
+ * @access public
+ */
+ public function start()
+ {
+ $task = $this->getTask();
+ $this->taskModification->update(array('id' => $task['id'], 'date_started' => time()));
+ $this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])));
+ }
}
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/Core/Helper.php b/app/Core/Helper.php
index 53084a7e..64eaed23 100644
--- a/app/Core/Helper.php
+++ b/app/Core/Helper.php
@@ -2,6 +2,8 @@
namespace Core;
+use Pimple\Container;
+
/**
* Helper base class
*
@@ -10,7 +12,7 @@ namespace Core;
*
* @property \Helper\App $app
* @property \Helper\Asset $asset
- * @property \Helper\Datetime $datetime
+ * @property \Helper\Dt $dt
* @property \Helper\File $file
* @property \Helper\Form $form
* @property \Helper\Subtask $subtask
@@ -19,16 +21,34 @@ namespace Core;
* @property \Helper\Url $url
* @property \Helper\User $user
*/
-class Helper extends Base
+class Helper
{
/**
* Helper instances
*
- * @static
* @access private
* @var array
*/
- private static $helpers = array();
+ private $helpers = array();
+
+ /**
+ * Container instance
+ *
+ * @access protected
+ * @var \Pimple\Container
+ */
+ protected $container;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param \Pimple\Container $container
+ */
+ public function __construct(Container $container)
+ {
+ $this->container = $container;
+ }
/**
* Load automatically helpers
@@ -39,12 +59,12 @@ class Helper extends Base
*/
public function __get($name)
{
- if (! isset(self::$helpers[$name])) {
+ if (! isset($this->helpers[$name])) {
$class = '\Helper\\'.ucfirst($name);
- self::$helpers[$name] = new $class($this->container);
+ $this->helpers[$name] = new $class($this->container);
}
- return self::$helpers[$name];
+ return $this->helpers[$name];
}
/**
diff --git a/app/Helper/Board.php b/app/Helper/Board.php
new file mode 100644
index 00000000..452a3b70
--- /dev/null
+++ b/app/Helper/Board.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Helper;
+
+/**
+ * Board Helper
+ *
+ * @package helper
+ * @author Frederic Guillot
+ */
+class Board extends \Core\Base
+{
+ /**
+ * Return true if tasks are collapsed
+ *
+ * @access public
+ * @param integer $project_id
+ * @return boolean
+ */
+ public function isCollapsed($project_id)
+ {
+ return $this->userSession->isBoardCollapsed($project_id);
+ }
+}
diff --git a/app/Helper/Datetime.php b/app/Helper/Dt.php
index 74ea9bdd..b338fdc8 100644
--- a/app/Helper/Datetime.php
+++ b/app/Helper/Dt.php
@@ -2,15 +2,35 @@
namespace Helper;
+use DateTime;
+
/**
* DateTime helpers
*
* @package helper
* @author Frederic Guillot
*/
-class Datetime extends \Core\Base
+class Dt extends \Core\Base
{
/**
+ * Get duration in seconds into human format
+ *
+ * @access public
+ * @param integer $seconds
+ * @return string
+ */
+ public function duration($seconds)
+ {
+ if ($seconds == 0) {
+ return 0;
+ }
+
+ $dtF = new DateTime("@0");
+ $dtT = new DateTime("@$seconds");
+ return $dtF->diff($dtT)->format('%a days, %h hours, %i minutes and %s seconds');
+ }
+
+ /**
* Get the age of an item in quasi human readable format.
* It's in this format: <1h , NNh, NNd
*
diff --git a/app/Helper/Url.php b/app/Helper/Url.php
index e133f195..8de63f8d 100644
--- a/app/Helper/Url.php
+++ b/app/Helper/Url.php
@@ -99,6 +99,10 @@ class Url extends \Core\Base
*/
public function server()
{
+ if (empty($_SERVER['SERVER_NAME'])) {
+ return 'http://localhost/';
+ }
+
$self = str_replace('\\', '/', dirname($_SERVER['PHP_SELF']));
$url = Request::isHTTPS() ? 'https://' : 'http://';
diff --git a/app/Integration/SlackWebhook.php b/app/Integration/SlackWebhook.php
index 975ea21f..498cea09 100644
--- a/app/Integration/SlackWebhook.php
+++ b/app/Integration/SlackWebhook.php
@@ -40,6 +40,25 @@ class SlackWebhook extends \Core\Base
}
/**
+ * Get optional Slack channel
+ *
+ * @access public
+ * @param integer $project_id
+ * @return string
+ */
+ public function getChannel($project_id)
+ {
+ $channel = $this->config->get('integration_slack_webhook_channel');
+
+ if (! empty($channel)) {
+ return $channel;
+ }
+
+ $options = $this->projectIntegration->getParameters($project_id);
+ return $options['slack_webhook_channel'];
+ }
+
+ /**
* Send message to the incoming Slack webhook
*
* @access public
@@ -69,6 +88,11 @@ class SlackWebhook extends \Core\Base
$payload['text'] .= '|'.t('view the task on Kanboard').'>';
}
+ $channel = $this->getChannel($project_id);
+ if (! empty($channel)) {
+ $payload['channel'] = $channel;
+ }
+
$this->httpClient->postJson($this->getWebhookUrl($project_id), $payload);
}
}
diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php
index 33c14c02..4690edea 100644
--- a/app/Locale/sv_SE/translations.php
+++ b/app/Locale/sv_SE/translations.php
@@ -20,15 +20,15 @@ return array(
'Red' => 'Röd',
'Orange' => 'Orange',
'Grey' => 'Grå',
- // 'Brown' => '',
- // 'Deep Orange' => '',
- // 'Dark Grey' => '',
- // 'Pink' => '',
- // 'Teal' => '',
- // 'Cyan' => '',
- // 'Lime' => '',
- // 'Light Green' => '',
- // 'Amber' => '',
+ 'Brown' => 'Brun',
+ 'Deep Orange' => 'Mörkorange',
+ 'Dark Grey' => 'Mörkgrå',
+ 'Pink' => 'Rosa',
+ 'Teal' => 'Grönblå',
+ 'Cyan' => 'Cyan',
+ 'Lime' => 'Lime',
+ 'Light Green' => 'Ljusgrön',
+ 'Amber' => 'Bärnsten',
'Save' => 'Spara',
'Login' => 'Login',
'Official website:' => 'Officiell webbsida:',
@@ -743,7 +743,7 @@ return array(
'Move the task to another column when assigned to a user' => 'Flytta uppgiften till en annan kolumn när den tilldelats en användare',
'Move the task to another column when assignee is cleared' => 'Flytta uppgiften till en annan kolumn när tilldelningen tas bort.',
'Source column' => 'Källkolumn',
- // 'Show subtask estimates (forecast of future work)' => '',
+ 'Show subtask estimates (forecast of future work)' => 'Visa uppskattningar för deluppgifter (prognos för framtida arbete)',
'Transitions' => 'Övergångar',
'Executer' => 'Verkställare',
'Time spent in the column' => 'Tid i kolumnen.',
@@ -788,187 +788,187 @@ return array(
'Burndown chart for "%s"' => 'Burndown diagram för "%s"',
'Burndown chart' => 'Burndown diagram',
'This chart show the task complexity over the time (Work Remaining).' => 'Diagrammet visar uppgiftens svårighet över tid (återstående arbete).',
- // 'Screenshot taken %s' => '',
- // 'Add a screenshot' => '',
- // 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '',
- // 'Screenshot uploaded successfully.' => '',
+ 'Screenshot taken %s' => 'Skärmdump tagen %s',
+ 'Add a screenshot' => 'Lägg till en skärmdump',
+ 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Ta en skärmdump och tryck CTRL+V för att klistra in här.',
+ 'Screenshot uploaded successfully.' => 'Skärmdumpen laddades upp.',
'SEK - Swedish Krona' => 'SEK - Svensk Krona',
- // 'The project identifier is an optional alphanumeric code used to identify your project.' => '',
- // 'Identifier' => '',
- // 'Postmark (incoming emails)' => '',
- // 'Help on Postmark integration' => '',
- // 'Mailgun (incoming emails)' => '',
- // 'Help on Mailgun integration' => '',
+ 'The project identifier is an optional alphanumeric code used to identify your project.' => 'Projektidentifieraren är en valbar alfanumerisk kod som används för att identifiera ditt projekt.',
+ 'Identifier' => 'Identifierare',
+ 'Postmark (incoming emails)' => 'Postmark (inkommande e-post)',
+ 'Help on Postmark integration' => 'Hjälp för Postmark integration',
+ 'Mailgun (incoming emails)' => 'Mailgrun (inkommande e-post)',
+ 'Help on Mailgun integration' => 'Hjälp för Mailgrun integration',
// 'Sendgrid (incoming emails)' => '',
- // 'Help on Sendgrid integration' => '',
- // 'Disable two factor authentication' => '',
- // 'Do you really want to disable the two factor authentication for this user: "%s"?' => '',
- // 'Edit link' => '',
- // 'Start to type task title...' => '',
- // 'A task cannot be linked to itself' => '',
- // 'The exact same link already exists' => '',
- // 'Recurrent task is scheduled to be generated' => '',
- // 'Recurring information' => '',
- // 'Score' => '',
- // 'The identifier must be unique' => '',
- // 'This linked task id doesn\'t exists' => '',
- // 'This value must be alphanumeric' => '',
- // 'Edit recurrence' => '',
- // 'Generate recurrent task' => '',
- // 'Trigger to generate recurrent task' => '',
- // 'Factor to calculate new due date' => '',
- // 'Timeframe to calculate new due date' => '',
- // 'Base date to calculate new due date' => '',
- // 'Action date' => '',
- // 'Base date to calculate new due date: ' => '',
- // 'This task has created this child task: ' => '',
- // 'Day(s)' => '',
- // 'Existing due date' => '',
- // 'Factor to calculate new due date: ' => '',
- // 'Month(s)' => '',
- // 'Recurrence' => '',
- // 'This task has been created by: ' => '',
- // 'Recurrent task has been generated:' => '',
- // 'Timeframe to calculate new due date: ' => '',
- // 'Trigger to generate recurrent task: ' => '',
- // 'When task is closed' => '',
- // 'When task is moved from first column' => '',
- // 'When task is moved to last column' => '',
- // 'Year(s)' => '',
- // 'Jabber (XMPP)' => '',
- // 'Send notifications to Jabber' => '',
- // 'XMPP server address' => '',
- // 'Jabber domain' => '',
- // 'Jabber nickname' => '',
- // 'Multi-user chat room' => '',
- // 'Help on Jabber integration' => '',
- // 'The server address must use this format: "tcp://hostname:5222"' => '',
- // 'Calendar settings' => '',
- // 'Project calendar view' => '',
- // 'Project settings' => '',
- // 'Show subtasks based on the time tracking' => '',
- // 'Show tasks based on the creation date' => '',
- // 'Show tasks based on the start date' => '',
- // 'Subtasks time tracking' => '',
- // 'User calendar view' => '',
- // 'Automatically update the start date' => '',
- // 'iCal feed' => '',
- // 'Preferences' => '',
- // 'Security' => '',
- // 'Two factor authentication disabled' => '',
- // 'Two factor authentication enabled' => '',
- // 'Unable to update this user.' => '',
- // 'There is no user management for private projects.' => '',
- // 'User that will receive the email' => '',
- // 'Email subject' => '',
- // 'Date' => '',
- // 'By @%s on Bitbucket' => '',
- // 'Bitbucket Issue' => '',
- // 'Commit made by @%s on Bitbucket' => '',
- // 'Commit made by @%s on Github' => '',
- // 'By @%s on Github' => '',
- // 'Commit made by @%s on Gitlab' => '',
- // 'Add a comment log when moving the task between columns' => '',
- // 'Move the task to another column when the category is changed' => '',
- // 'Send a task by email to someone' => '',
- // 'Reopen a task' => '',
- // 'Bitbucket issue opened' => '',
- // 'Bitbucket issue closed' => '',
- // 'Bitbucket issue reopened' => '',
- // 'Bitbucket issue assignee change' => '',
- // 'Bitbucket issue comment created' => '',
- // 'Column change' => '',
- // 'Position change' => '',
- // 'Swimlane change' => '',
- // 'Assignee change' => '',
- // '[%s] Overdue tasks' => '',
- // 'Notification' => '',
- // '%s moved the task #%d to the first swimlane' => '',
- // '%s moved the task #%d to the swimlane "%s"' => '',
- // 'Swimlane' => '',
- // 'Budget overview' => '',
- // 'Type' => '',
- // 'There is not enough data to show something.' => '',
- // 'Gravatar' => '',
- // 'Hipchat' => '',
- // 'Slack' => '',
- // '%s moved the task %s to the first swimlane' => '',
- // '%s moved the task %s to the swimlane "%s"' => '',
- // 'This report contains all subtasks information for the given date range.' => '',
- // 'This report contains all tasks information for the given date range.' => '',
- // 'Project activities for %s' => '',
- // 'view the board on Kanboard' => '',
- // 'The task have been moved to the first swimlane' => '',
- // 'The task have been moved to another swimlane:' => '',
- // 'Overdue tasks for the project "%s"' => '',
- // 'New title: %s' => '',
- // 'The task is not assigned anymore' => '',
- // 'New assignee: %s' => '',
- // 'There is no category now' => '',
- // 'New category: %s' => '',
- // 'New color: %s' => '',
- // 'New complexity: %d' => '',
- // 'The due date have been removed' => '',
- // 'There is no description anymore' => '',
- // 'Recurrence settings have been modified' => '',
- // 'Time spent changed: %sh' => '',
- // 'Time estimated changed: %sh' => '',
- // 'The field "%s" have been updated' => '',
- // 'The description have been modified' => '',
- // 'Do you really want to close the task "%s" as well as all subtasks?' => '',
- // 'Swimlane: %s' => '',
- // 'I want to receive notifications for:' => '',
- // 'All tasks' => '',
- // 'Only for tasks assigned to me' => '',
- // 'Only for tasks created by me' => '',
- // 'Only for tasks created by me and assigned to me' => '',
- // '%A' => '',
- // '%b %e, %Y, %k:%M %p' => '',
- // 'New due date: %B %e, %Y' => '',
- // 'Start date changed: %B %e, %Y' => '',
- // '%k:%M %p' => '',
- // '%%Y-%%m-%%d' => '',
- // 'Total for all columns' => '',
- // 'You need at least 2 days of data to show the chart.' => '',
- // '<15m' => '',
- // '<30m' => '',
- // 'Stop timer' => '',
- // 'Start timer' => '',
- // 'Add project member' => '',
- // 'Enable notifications' => '',
- // 'My activity stream' => '',
- // 'My calendar' => '',
- // 'Search tasks' => '',
- // 'Back to the calendar' => '',
- // 'Filters' => '',
- // 'Reset filters' => '',
- // 'My tasks due tomorrow' => '',
- // 'Tasks due today' => '',
- // 'Tasks due tomorrow' => '',
- // 'Tasks due yesterday' => '',
- // 'Closed tasks' => '',
- // 'Open tasks' => '',
- // 'Not assigned' => '',
- // 'View advanced search syntax' => '',
- // 'Overview' => '',
- // '%b %e %Y' => '',
- // 'Board/Calendar/List view' => '',
- // 'Switch to the board view' => '',
- // 'Switch to the calendar view' => '',
- // 'Switch to the list view' => '',
- // 'Go to the search/filter box' => '',
- // 'There is no activity yet.' => '',
- // 'No tasks found.' => '',
- // 'Keyboard shortcut: "%s"' => '',
- // 'List' => '',
- // 'Filter' => '',
- // 'Advanced search' => '',
- // 'Example of query: ' => '',
- // 'Search by project: ' => '',
- // 'Search by column: ' => '',
- // 'Search by assignee: ' => '',
- // 'Search by color: ' => '',
- // 'Search by category: ' => '',
- // 'Search by description: ' => '',
- // 'Search by due date: ' => '',
+ 'Help on Sendgrid integration' => 'Hjälp för Sendgrid integration',
+ 'Disable two factor authentication' => 'Inaktivera två-faktors autentisering',
+ 'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Vill du verkligen inaktivera två-faktors autentisering för denna användare: "%s"?',
+ 'Edit link' => 'Ändra länk',
+ 'Start to type task title...' => 'Börja skriv uppgiftstitel...',
+ 'A task cannot be linked to itself' => 'En uppgift kan inte länkas till sig själv',
+ 'The exact same link already exists' => 'Länken existerar redan',
+ 'Recurrent task is scheduled to be generated' => 'Återkommande uppgift är schemalagd att genereras',
+ 'Recurring information' => 'Ã…terkommande information',
+ 'Score' => 'Poäng',
+ 'The identifier must be unique' => 'Identifieraren måste vara unik',
+ 'This linked task id doesn\'t exists' => 'Denna länkade uppgifts id existerar inte',
+ 'This value must be alphanumeric' => 'Värdet måste vara alfanumeriskt',
+ 'Edit recurrence' => 'Ändra återkommande',
+ 'Generate recurrent task' => 'Generera återkommande uppgift',
+ 'Trigger to generate recurrent task' => 'Aktivera att generera återkommande uppgift',
+ 'Factor to calculate new due date' => 'Faktor för att beräkna nytt datum',
+ 'Timeframe to calculate new due date' => 'Tidsram för att beräkna nytt datum',
+ 'Base date to calculate new due date' => 'Basdatum för att beräkna nytt datum',
+ 'Action date' => 'Händelsedatum',
+ 'Base date to calculate new due date: ' => 'Basdatum för att beräkna nytt förfallodatum: ',
+ 'This task has created this child task: ' => 'Uppgiften har skapat denna underliggande uppgift: ',
+ 'Day(s)' => 'Dag(ar)',
+ 'Existing due date' => 'Existerande förfallodatum',
+ 'Factor to calculate new due date: ' => 'Faktor för att beräkna nytt förfallodatum: ',
+ 'Month(s)' => 'MÃ¥nad(er)',
+ 'Recurrence' => 'Ã…terkommande',
+ 'This task has been created by: ' => 'Uppgiften har skapats av: ',
+ 'Recurrent task has been generated:' => 'Ã…terkommande uppgift har genererats:',
+ 'Timeframe to calculate new due date: ' => 'Tidsram för att beräkna nytt förfallodatum: ',
+ 'Trigger to generate recurrent task: ' => 'Aktivera att generera återkommande uppgift: ',
+ 'When task is closed' => 'När uppgiften är stängd',
+ 'When task is moved from first column' => 'När uppgiften flyttas från första kolumnen',
+ 'When task is moved to last column' => 'När uppgiften flyttas till sista kolumnen',
+ 'Year(s)' => 'Ã…r',
+ 'Jabber (XMPP)' => 'Jabber (XMPP)',
+ 'Send notifications to Jabber' => 'Skicka notiser till Jabber',
+ 'XMPP server address' => 'XMPP serveradress',
+ 'Jabber domain' => 'Jabber domän',
+ 'Jabber nickname' => 'Jabber smeknamn',
+ 'Multi-user chat room' => 'Multi-user chatrum',
+ 'Help on Jabber integration' => 'Hjälp för Jabber integration',
+ 'The server address must use this format: "tcp://hostname:5222"' => 'Serveradressen måste använda detta format: "tcp://hostname:5222"',
+ 'Calendar settings' => 'Inställningar för kalendern',
+ 'Project calendar view' => 'Projektkalendervy',
+ 'Project settings' => 'Projektinställningar',
+ 'Show subtasks based on the time tracking' => 'Visa deluppgifter baserade på tidsspårning',
+ 'Show tasks based on the creation date' => 'Visa uppgifter baserade på skapat datum',
+ 'Show tasks based on the start date' => 'Visa uppgifter baserade på startdatum',
+ 'Subtasks time tracking' => 'Deluppgifter tidsspårning',
+ 'User calendar view' => 'Användarkalendervy',
+ 'Automatically update the start date' => 'Automatisk uppdatering av startdatum',
+ 'iCal feed' => 'iCal flöde',
+ 'Preferences' => 'Preferenser',
+ 'Security' => 'Säkerhet',
+ 'Two factor authentication disabled' => 'Tvåfaktorsverifiering inaktiverad',
+ 'Two factor authentication enabled' => 'Tvåfaktorsverifiering aktiverad',
+ 'Unable to update this user.' => 'Kunde inte uppdatera användaren.',
+ 'There is no user management for private projects.' => 'Det finns ingen användarhantering för privata projekt.',
+ 'User that will receive the email' => 'Användare som kommer att ta emot mailet',
+ 'Email subject' => 'E-post ämne',
+ 'Date' => 'Datum',
+ 'By @%s on Bitbucket' => 'Av @%s på Bitbucket',
+ 'Bitbucket Issue' => 'Bitbucket fråga',
+ 'Commit made by @%s on Bitbucket' => 'Bidrag gjort av @%s på Bitbucket',
+ 'Commit made by @%s on Github' => 'Bidrag gjort av @%s på GitHub',
+ 'By @%s on Github' => 'Av @%s på GitHub',
+ 'Commit made by @%s on Gitlab' => 'Bidrag gjort av @%s på Gitlab',
+ 'Add a comment log when moving the task between columns' => 'Lägg till en kommentarslogg när en uppgift flyttas mellan kolumner',
+ 'Move the task to another column when the category is changed' => 'Flyttas uppgiften till en annan kolumn när kategorin ändras',
+ 'Send a task by email to someone' => 'Skicka en uppgift med e-post till någon',
+ 'Reopen a task' => 'Återöppna en uppgift',
+ 'Bitbucket issue opened' => 'Bitbucketfråga öppnad',
+ 'Bitbucket issue closed' => 'Bitbucketfråga stängd',
+ 'Bitbucket issue reopened' => 'Bitbucketfråga återöppnad',
+ 'Bitbucket issue assignee change' => 'Bitbucketfråga tilldelningsändring',
+ 'Bitbucket issue comment created' => 'Bitbucketfråga kommentar skapad',
+ 'Column change' => 'Kolumnändring',
+ 'Position change' => 'Positionsändring',
+ 'Swimlane change' => 'Swimlaneändring',
+ 'Assignee change' => 'Tilldelningsändring',
+ '[%s] Overdue tasks' => '[%s] Försenade uppgifter',
+ 'Notification' => 'Notis',
+ '%s moved the task #%d to the first swimlane' => '%s flyttade uppgiften #%d till första swimlane',
+ '%s moved the task #%d to the swimlane "%s"' => '%s flyttade uppgiften #%d till swimlane "%s"',
+ 'Swimlane' => 'Swimlane',
+ 'Budget overview' => 'Budgetöversikt',
+ 'Type' => 'Typ',
+ 'There is not enough data to show something.' => 'Det finns inte tillräckligt mycket data för att visa något.',
+ 'Gravatar' => 'Gravatar',
+ 'Hipchat' => 'Hipchat',
+ 'Slack' => 'Slack',
+ '%s moved the task %s to the first swimlane' => '%s flyttade uppgiften %s till första swimlane',
+ '%s moved the task %s to the swimlane "%s"' => '%s flyttade uppgiften %s till swimlane "%s"',
+ 'This report contains all subtasks information for the given date range.' => 'Denna rapport innehåller all deluppgiftsinformation för det givna datumintervallet.',
+ 'This report contains all tasks information for the given date range.' => 'Denna rapport innehåller all uppgiftsinformation för det givna datumintervallet.',
+ 'Project activities for %s' => 'Projektaktiviteter för %s',
+ 'view the board on Kanboard' => 'visa tavlan på Kanboard',
+ 'The task have been moved to the first swimlane' => 'Uppgiften har flyttats till första swimlane',
+ 'The task have been moved to another swimlane:' => 'Uppgiften har flyttats till en annan swimlane:',
+ 'Overdue tasks for the project "%s"' => 'Försenade uppgifter för projektet "%s"',
+ 'New title: %s' => 'Ny titel: %s',
+ 'The task is not assigned anymore' => 'Uppgiften är inte länge tilldelad',
+ 'New assignee: %s' => 'Ny tilldelning: %s',
+ 'There is no category now' => 'Det finns ingen kategori nu',
+ 'New category: %s' => 'Ny kategori: %s',
+ 'New color: %s' => 'Ny färg: %s',
+ 'New complexity: %d' => 'Ny komplexitet: %d',
+ 'The due date have been removed' => 'Förfallodatumet har tagits bort',
+ 'There is no description anymore' => 'Det finns ingen beskrivning längre',
+ 'Recurrence settings have been modified' => 'Återkommande inställning har ändrats',
+ 'Time spent changed: %sh' => 'Spenderad tid har ändrats: %sh',
+ 'Time estimated changed: %sh' => 'Tidsuppskattning ändrad: %sh',
+ 'The field "%s" have been updated' => 'Fältet "%s" har uppdaterats',
+ 'The description have been modified' => 'Beskrivningen har modifierats',
+ 'Do you really want to close the task "%s" as well as all subtasks?' => 'Vill du verkligen stänga uppgiften "%s" och alla deluppgifter?',
+ 'Swimlane: %s' => 'Swimlane: %s',
+ 'I want to receive notifications for:' => 'Jag vill få notiser för:',
+ 'All tasks' => 'Alla uppgifter',
+ 'Only for tasks assigned to me' => 'Bara för uppgifter tilldelade mig',
+ 'Only for tasks created by me' => 'Bara för uppgifter skapade av mig',
+ 'Only for tasks created by me and assigned to me' => 'Bara för uppgifter skapade av mig och tilldelade till mig',
+ '%A' => '%A',
+ '%b %e, %Y, %k:%M %p' => '%b %e, %Y, %k:%M %p',
+ 'New due date: %B %e, %Y' => 'Nytt förfallodatum: %B %e, %Y',
+ 'Start date changed: %B %e, %Y' => 'Startdatum ändrat: %B %e, %Y',
+ '%k:%M %p' => '%k:%M %p',
+ '%%Y-%%m-%%d' => '%%Y-%%m-%%d',
+ 'Total for all columns' => 'Totalt för alla kolumner',
+ 'You need at least 2 days of data to show the chart.' => 'Du behöver minst två dagars data för att visa diagrammet.',
+ '<15m' => '<15m',
+ '<30m' => '<30m',
+ 'Stop timer' => 'Stoppa timer',
+ 'Start timer' => 'Starta timer',
+ 'Add project member' => 'Lägg till projektmedlem',
+ 'Enable notifications' => 'Aktivera notiser',
+ 'My activity stream' => 'Min aktivitetsström',
+ 'My calendar' => 'Min kalender',
+ 'Search tasks' => 'Sök uppgifter',
+ 'Back to the calendar' => 'Tillbaka till kalendern',
+ 'Filters' => 'Filter',
+ 'Reset filters' => 'Återställ filter',
+ 'My tasks due tomorrow' => 'Mina uppgifter förfaller imorgon',
+ 'Tasks due today' => 'Uppgifter förfaller idag',
+ 'Tasks due tomorrow' => 'Uppgifter förfaller imorgon',
+ 'Tasks due yesterday' => 'Uppgifter förföll igår',
+ 'Closed tasks' => 'Stängda uppgifter',
+ 'Open tasks' => 'Öppna uppgifter',
+ 'Not assigned' => 'Inte tilldelad',
+ 'View advanced search syntax' => 'Visa avancerad söksyntax',
+ 'Overview' => 'Översikts',
+ '%b %e %Y' => '%b %e %Y',
+ 'Board/Calendar/List view' => 'Tavla/Kalender/Listvy',
+ 'Switch to the board view' => 'Växla till tavelvy',
+ 'Switch to the calendar view' => 'Växla till kalendervy',
+ 'Switch to the list view' => 'Växla till listvy',
+ 'Go to the search/filter box' => 'Gå till sök/filter box',
+ 'There is no activity yet.' => 'Det finns ingen aktivitet ännu.',
+ 'No tasks found.' => 'Inga uppgifter hittades.',
+ 'Keyboard shortcut: "%s"' => 'Tangentbordsgenväg: "%s"',
+ 'List' => 'Lista',
+ 'Filter' => 'Filter',
+ 'Advanced search' => 'Avancerad sök',
+ 'Example of query: ' => 'Exempel på fråga',
+ 'Search by project: ' => 'Sök efter projekt:',
+ 'Search by column: ' => 'Sök efter kolumn:',
+ 'Search by assignee: ' => 'Sök efter tilldelad:',
+ 'Search by color: ' => 'Sök efter färg:',
+ 'Search by category: ' => 'Sök efter kategori:',
+ 'Search by description: ' => 'Sök efter beskrivning',
+ 'Search by due date: ' => 'Sök efter förfallodatum',
);
diff --git a/app/Model/Authentication.php b/app/Model/Authentication.php
index 86c1c43f..31969b57 100644
--- a/app/Model/Authentication.php
+++ b/app/Model/Authentication.php
@@ -49,11 +49,6 @@ class Authentication extends Base
return false;
}
- // We update each time the RememberMe cookie tokens
- if ($this->backend('rememberMe')->hasCookie()) {
- $this->backend('rememberMe')->refresh();
- }
-
return true;
}
diff --git a/app/Model/DateParser.php b/app/Model/DateParser.php
index be79a92e..79a8385c 100644
--- a/app/Model/DateParser.php
+++ b/app/Model/DateParser.php
@@ -85,8 +85,7 @@ class DateParser extends Base
*/
public function getTimestamp($value)
{
- foreach ($this->getDateFormats() as $format) {
-
+ foreach ($this->getAllFormats() as $format) {
$timestamp = $this->getValidDate($value, $format);
if ($timestamp !== 0) {
@@ -98,6 +97,25 @@ class DateParser extends Base
}
/**
+ * Get all combinations of date/time formats
+ *
+ * @access public
+ * @return []string
+ */
+ public function getAllFormats()
+ {
+ $formats = array();
+
+ foreach ($this->getDateFormats() as $date) {
+ foreach ($this->getTimeFormats() as $time) {
+ $formats[] = $date.' '.$time;
+ }
+ }
+
+ return array_merge($formats, $this->getDateFormats());
+ }
+
+ /**
* Return the list of supported date formats (for the parser)
*
* @access public
@@ -113,6 +131,21 @@ class DateParser extends Base
}
/**
+ * Return the list of supported time formats (for the parser)
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getTimeFormats()
+ {
+ return array(
+ 'H:i',
+ 'g:i A',
+ 'g:iA',
+ );
+ }
+
+ /**
* Return the list of available date formats (for the config page)
*
* @access public
@@ -143,7 +176,7 @@ class DateParser extends Base
* Get a timetstamp from an ISO date format
*
* @access public
- * @param string $date Date format
+ * @param string $date
* @return integer
*/
public function getTimestampFromIsoFormat($date)
@@ -166,7 +199,6 @@ class DateParser extends Base
}
foreach ($fields as $field) {
-
if (! empty($values[$field])) {
$values[$field] = date($format, $values[$field]);
}
@@ -180,15 +212,16 @@ class DateParser extends Base
* Convert date (form input data)
*
* @access public
- * @param array $values Database values
- * @param string[] $fields Date fields
+ * @param array $values Database values
+ * @param string[] $fields Date fields
+ * @param boolean $keep_time Keep time or not
*/
- public function convert(array &$values, array $fields)
+ public function convert(array &$values, array $fields, $keep_time = false)
{
foreach ($fields as $field) {
-
if (! empty($values[$field]) && ! is_numeric($values[$field])) {
- $values[$field] = $this->removeTimeFromTimestamp($this->getTimestamp($values[$field]));
+ $timestamp = $this->getTimestamp($values[$field]);
+ $values[$field] = $keep_time ? $timestamp : $this->removeTimeFromTimestamp($timestamp);
}
}
}
diff --git a/app/Model/ProjectAnalytic.php b/app/Model/ProjectAnalytic.php
index a663f921..1ee8a405 100644
--- a/app/Model/ProjectAnalytic.php
+++ b/app/Model/ProjectAnalytic.php
@@ -49,7 +49,7 @@ class ProjectAnalytic extends Base
* Get users repartition
*
* @access public
- * @param integer $project_id Project id
+ * @param integer $project_id
* @return array
*/
public function getUserRepartition($project_id)
@@ -87,4 +87,96 @@ 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
+ *
+ * @access public
+ * @param integer $project_id
+ * @return array
+ */
+ public function getAverageTimeSpentByColumn($project_id)
+ {
+ $stats = array();
+ $columns = $this->board->getColumnsList($project_id);
+
+ // Get the time spent of the last move for each tasks
+ $tasks = $this->db
+ ->table(Task::TABLE)
+ ->columns('id', 'date_completed', 'date_moved', 'column_id')
+ ->eq('project_id', $project_id)
+ ->desc('id')
+ ->limit(1000)
+ ->findAll();
+
+ // Init values
+ foreach ($columns as $column_id => $column_title) {
+ $stats[$column_id] = array(
+ 'count' => 0,
+ 'time_spent' => 0,
+ 'average' => 0,
+ 'title' => $column_title,
+ );
+ }
+
+ // Get time spent foreach task/column and take into account the last move
+ foreach ($tasks as &$task) {
+ $sums = $this->transition->getTimeSpentByTask($task['id']);
+
+ if (! isset($sums[$task['column_id']])) {
+ $sums[$task['column_id']] = 0;
+ }
+
+ $sums[$task['column_id']] += ($task['date_completed'] ?: time()) - $task['date_moved'];
+
+ foreach ($sums as $column_id => $time_spent) {
+ $stats[$column_id]['count']++;
+ $stats[$column_id]['time_spent'] += $time_spent;
+ }
+ }
+
+ // Calculate average for each column
+ foreach ($columns as $column_id => $column_title) {
+ $stats[$column_id]['average'] = (int) ($stats[$column_id]['time_spent'] / $stats[$column_id]['count']);
+ }
+
+ return $stats;
+ }
}
diff --git a/app/Model/ProjectDailySummary.php b/app/Model/ProjectDailyColumnStats.php
index 04dc5629..26e9d8b7 100644
--- a/app/Model/ProjectDailySummary.php
+++ b/app/Model/ProjectDailyColumnStats.php
@@ -3,22 +3,22 @@
namespace Model;
/**
- * Project daily summary
+ * Project Daily Column Stats
*
* @package model
* @author Frederic Guillot
*/
-class ProjectDailySummary extends Base
+class ProjectDailyColumnStats extends Base
{
/**
* SQL table name
*
* @var string
*/
- const TABLE = 'project_daily_summaries';
+ const TABLE = 'project_daily_column_stats';
/**
- * Update daily totals for the project
+ * Update daily totals for the project and foreach column
*
* "total" is the number open of tasks in the column
* "score" is the sum of tasks score in the column
@@ -38,7 +38,7 @@ class ProjectDailySummary extends Base
// 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(
+ $db->table(ProjectDailyColumnStats::TABLE)->insert(array(
'day' => $date,
'project_id' => $project_id,
'column_id' => $column_id,
@@ -46,7 +46,7 @@ class ProjectDailySummary extends Base
'score' => 0,
));
- $db->table(ProjectDailySummary::TABLE)
+ $db->table(ProjectDailyColumnStats::TABLE)
->eq('project_id', $project_id)
->eq('column_id', $column_id)
->eq('day', $date)
@@ -95,19 +95,19 @@ class ProjectDailySummary extends Base
*/
public function getRawMetrics($project_id, $from, $to)
{
- return $this->db->table(ProjectDailySummary::TABLE)
+ return $this->db->table(ProjectDailyColumnStats::TABLE)
->columns(
- ProjectDailySummary::TABLE.'.column_id',
- ProjectDailySummary::TABLE.'.day',
- ProjectDailySummary::TABLE.'.total',
- ProjectDailySummary::TABLE.'.score',
+ 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(ProjectDailySummary::TABLE.'.project_id', $project_id)
+ ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id)
->gte('day', $from)
->lte('day', $to)
- ->asc(ProjectDailySummary::TABLE.'.day')
+ ->asc(ProjectDailyColumnStats::TABLE.'.day')
->findAll();
}
@@ -122,17 +122,17 @@ class ProjectDailySummary extends Base
*/
public function getRawMetricsByDay($project_id, $from, $to)
{
- return $this->db->table(ProjectDailySummary::TABLE)
+ return $this->db->table(ProjectDailyColumnStats::TABLE)
->columns(
- ProjectDailySummary::TABLE.'.day',
- 'SUM('.ProjectDailySummary::TABLE.'.total) AS total',
- 'SUM('.ProjectDailySummary::TABLE.'.score) AS score'
+ ProjectDailyColumnStats::TABLE.'.day',
+ 'SUM('.ProjectDailyColumnStats::TABLE.'.total) AS total',
+ 'SUM('.ProjectDailyColumnStats::TABLE.'.score) AS score'
)
- ->eq(ProjectDailySummary::TABLE.'.project_id', $project_id)
+ ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id)
->gte('day', $from)
->lte('day', $to)
- ->asc(ProjectDailySummary::TABLE.'.day')
- ->groupBy(ProjectDailySummary::TABLE.'.day')
+ ->asc(ProjectDailyColumnStats::TABLE.'.day')
+ ->groupBy(ProjectDailyColumnStats::TABLE.'.day')
->findAll();
}
@@ -160,7 +160,7 @@ class ProjectDailySummary extends Base
$aggregates = array();
// Fetch metrics for the project
- $records = $this->db->table(ProjectDailySummary::TABLE)
+ $records = $this->db->table(ProjectDailyColumnStats::TABLE)
->eq('project_id', $project_id)
->gte('day', $from)
->lte('day', $to)
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 @@
+<?php
+
+namespace Model;
+
+/**
+ * Project Daily Stats
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class ProjectDailyStats extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'project_daily_stats';
+
+ /**
+ * 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)
+ {
+ $lead_cycle_time = $this->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/TaskAnalytic.php b/app/Model/TaskAnalytic.php
new file mode 100644
index 00000000..33a645c1
--- /dev/null
+++ b/app/Model/TaskAnalytic.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Model;
+
+/**
+ * Task Analytic
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class TaskAnalytic extends Base
+{
+ /**
+ * Get the time between date_creation and date_completed or now if empty
+ *
+ * @access public
+ * @param array $task
+ * @return integer
+ */
+ public function getLeadTime(array $task)
+ {
+ return ($task['date_completed'] ?: time()) - $task['date_creation'];
+ }
+
+ /**
+ * Get the time between date_started and date_completed or now if empty
+ *
+ * @access public
+ * @param array $task
+ * @return integer
+ */
+ public function getCycleTime(array $task)
+ {
+ if (empty($task['date_started'])) {
+ return 0;
+ }
+
+ return ($task['date_completed'] ?: time()) - $task['date_started'];
+ }
+
+ /**
+ * Get the average time spent in each column
+ *
+ * @access public
+ * @param array $task
+ * @return array
+ */
+ public function getTimeSpentByColumn(array $task)
+ {
+ $result = array();
+ $columns = $this->board->getColumnsList($task['project_id']);
+ $sums = $this->transition->getTimeSpentByTask($task['id']);
+
+ foreach ($columns as $column_id => $column_title) {
+
+ $time_spent = isset($sums[$column_id]) ? $sums[$column_id] : 0;
+
+ if ($task['column_id'] == $column_id) {
+ $time_spent += ($task['date_completed'] ?: time()) - $task['date_moved'];
+ }
+
+ $result[] = array(
+ 'id' => $column_id,
+ 'title' => $column_title,
+ 'time_spent' => $time_spent,
+ );
+ }
+
+ return $result;
+ }
+}
diff --git a/app/Model/TaskCreation.php b/app/Model/TaskCreation.php
index 893cbc43..e530da13 100644
--- a/app/Model/TaskCreation.php
+++ b/app/Model/TaskCreation.php
@@ -43,9 +43,10 @@ class TaskCreation extends Base
*/
public function prepare(array &$values)
{
- $this->dateParser->convert($values, array('date_due', 'date_started'));
+ $this->dateParser->convert($values, array('date_due'));
+ $this->dateParser->convert($values, array('date_started'), true);
$this->removeFields($values, array('another_task'));
- $this->resetFields($values, array('owner_id', 'swimlane_id', 'date_due', 'score', 'category_id', 'time_estimated'));
+ $this->resetFields($values, array('creator_id', 'owner_id', 'swimlane_id', 'date_due', 'score', 'category_id', 'time_estimated'));
if (empty($values['column_id'])) {
$values['column_id'] = $this->board->getFirstColumn($values['project_id']);
@@ -59,6 +60,10 @@ class TaskCreation extends Base
$values['title'] = t('Untitled');
}
+ if ($this->userSession->isLogged()) {
+ $values['creator_id'] = $this->userSession->getId();
+ }
+
$values['swimlane_id'] = empty($values['swimlane_id']) ? 0 : $values['swimlane_id'];
$values['date_creation'] = time();
$values['date_modification'] = $values['date_creation'];
diff --git a/app/Model/TaskFilter.php b/app/Model/TaskFilter.php
index 6cf10a17..dfd97d9d 100644
--- a/app/Model/TaskFilter.php
+++ b/app/Model/TaskFilter.php
@@ -118,7 +118,7 @@ class TaskFilter extends Base
* Exclude a list of task_id
*
* @access public
- * @param array $task_ids
+ * @param integer[] $task_ids
* @return TaskFilter
*/
public function excludeTasks(array $task_ids)
@@ -641,10 +641,10 @@ class TaskFilter extends Base
* Transform results to ical events
*
* @access public
- * @param string $start_column Column name for the start date
- * @param string $end_column Column name for the end date
- * @param Eluceo\iCal\Component\Calendar $vCalendar Calendar object
- * @return Eluceo\iCal\Component\Calendar
+ * @param string $start_column Column name for the start date
+ * @param string $end_column Column name for the end date
+ * @param Calendar $vCalendar Calendar object
+ * @return Calendar
*/
public function addDateTimeIcalEvents($start_column, $end_column, Calendar $vCalendar = null)
{
@@ -674,9 +674,9 @@ class TaskFilter extends Base
* Transform results to all day ical events
*
* @access public
- * @param string $column Column name for the date
- * @param Eluceo\iCal\Component\Calendar $vCalendar Calendar object
- * @return Eluceo\iCal\Component\Calendar
+ * @param string $column Column name for the date
+ * @param Calendar $vCalendar Calendar object
+ * @return Calendar
*/
public function addAllDayIcalEvents($column = 'date_due', Calendar $vCalendar = null)
{
@@ -706,7 +706,7 @@ class TaskFilter extends Base
* @access protected
* @param array $task
* @param string $uid
- * @return Eluceo\iCal\Component\Event
+ * @return Event
*/
protected function getTaskIcalEvent(array &$task, $uid)
{
@@ -723,11 +723,11 @@ class TaskFilter extends Base
$vEvent->setSummary(t('#%d', $task['id']).' '.$task['title']);
$vEvent->setUrl($this->helper->url->base().$this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
- if (! empty($task['creator_id'])) {
- $vEvent->setOrganizer('MAILTO:'.($task['creator_email'] ?: $task['creator_username'].'@kanboard.local'));
+ if (! empty($task['owner_id'])) {
+ $vEvent->setOrganizer('MAILTO:'.($task['assignee_email'] ?: $task['assignee_username'].'@kanboard.local'));
}
- if (! empty($task['owner_id'])) {
+ if (! empty($task['creator_id'])) {
$attendees = new Attendees;
$attendees->add('MAILTO:'.($task['creator_email'] ?: $task['creator_username'].'@kanboard.local'));
$vEvent->setAttendees($attendees);
diff --git a/app/Model/TaskLink.php b/app/Model/TaskLink.php
index 7d3a8918..3fdbd04b 100644
--- a/app/Model/TaskLink.php
+++ b/app/Model/TaskLink.php
@@ -4,7 +4,6 @@ namespace Model;
use SimpleValidator\Validator;
use SimpleValidator\Validators;
-use PicoDb\Table;
/**
* TaskLink model
diff --git a/app/Model/TaskModification.php b/app/Model/TaskModification.php
index 4691ce81..b67106e1 100644
--- a/app/Model/TaskModification.php
+++ b/app/Model/TaskModification.php
@@ -83,7 +83,8 @@ class TaskModification extends Base
*/
public function prepare(array &$values)
{
- $this->dateParser->convert($values, array('date_due', 'date_started'));
+ $this->dateParser->convert($values, array('date_due'));
+ $this->dateParser->convert($values, array('date_started'), true);
$this->removeFields($values, array('another_task', 'id'));
$this->resetFields($values, array('date_due', 'date_started', 'score', 'category_id', 'time_estimated', 'time_spent'));
$this->convertIntegerFields($values, array('is_active', 'recurrence_status', 'recurrence_trigger', 'recurrence_factor', 'recurrence_timeframe', 'recurrence_basedate'));
diff --git a/app/Model/TaskPosition.php b/app/Model/TaskPosition.php
index a33a4029..874633b1 100644
--- a/app/Model/TaskPosition.php
+++ b/app/Model/TaskPosition.php
@@ -26,109 +26,176 @@ class TaskPosition extends Base
*/
public function movePosition($project_id, $task_id, $column_id, $position, $swimlane_id = 0, $fire_events = true)
{
- $original_task = $this->taskFinder->getById($task_id);
+ if ($position < 1) {
+ return false;
+ }
+
+ $task = $this->taskFinder->getById($task_id);
// Ignore closed tasks
- if ($original_task['is_active'] == Task::STATUS_CLOSED) {
+ if ($task['is_active'] == Task::STATUS_CLOSED) {
return true;
}
- $result = $this->calculateAndSave($project_id, $task_id, $column_id, $position, $swimlane_id);
-
- if ($result) {
+ $result = false;
- if ($original_task['swimlane_id'] != $swimlane_id) {
- $this->calculateAndSave($project_id, 0, $column_id, 1, $original_task['swimlane_id']);
- }
+ if ($task['swimlane_id'] != $swimlane_id) {
+ $result = $this->saveSwimlaneChange($project_id, $task_id, $position, $task['column_id'], $column_id, $task['swimlane_id'], $swimlane_id);
+ }
+ else if ($task['column_id'] != $column_id) {
+ $result = $this->saveColumnChange($project_id, $task_id, $position, $swimlane_id, $task['column_id'], $column_id);
+ }
+ else if ($task['position'] != $position) {
+ $result = $this->savePositionChange($project_id, $task_id, $position, $column_id, $swimlane_id);
+ }
- if ($fire_events) {
- $this->fireEvents($original_task, $column_id, $position, $swimlane_id);
- }
+ if ($result && $fire_events) {
+ $this->fireEvents($task, $column_id, $position, $swimlane_id);
}
return $result;
}
/**
- * Calculate the new position of all tasks
+ * Move a task to another swimlane
*
- * @access public
- * @param integer $project_id Project id
- * @param integer $task_id Task id
- * @param integer $column_id Column id
- * @param integer $position Position (must be >= 1)
- * @param integer $swimlane_id Swimlane id
- * @return array|boolean
+ * @access private
+ * @param integer $project_id
+ * @param integer $task_id
+ * @param integer $position
+ * @param integer $original_column_id
+ * @param integer $new_column_id
+ * @param integer $original_swimlane_id
+ * @param integer $new_swimlane_id
+ * @return boolean
*/
- public function calculatePositions($project_id, $task_id, $column_id, $position, $swimlane_id = 0)
+ private function saveSwimlaneChange($project_id, $task_id, $position, $original_column_id, $new_column_id, $original_swimlane_id, $new_swimlane_id)
{
- // The position can't be lower than 1
- if ($position < 1) {
- return false;
- }
+ $this->db->startTransaction();
+ $r1 = $this->saveTaskPositions($project_id, $task_id, 0, $original_column_id, $original_swimlane_id);
+ $r2 = $this->saveTaskPositions($project_id, $task_id, $position, $new_column_id, $new_swimlane_id);
+ $this->db->closeTransaction();
- $board = $this->db->table(Board::TABLE)->eq('project_id', $project_id)->asc('position')->findAllByColumn('id');
- $columns = array();
-
- // For each column fetch all tasks ordered by position
- foreach ($board as $board_column_id) {
-
- $columns[$board_column_id] = $this->db->table(Task::TABLE)
- ->eq('is_active', 1)
- ->eq('swimlane_id', $swimlane_id)
- ->eq('project_id', $project_id)
- ->eq('column_id', $board_column_id)
- ->neq('id', $task_id)
- ->asc('position')
- ->asc('id') // Fix Postgresql unit test
- ->findAllByColumn('id');
- }
+ return $r1 && $r2;
+ }
- // The column must exists
- if (! isset($columns[$column_id])) {
- return false;
- }
+ /**
+ * Move a task to another column
+ *
+ * @access private
+ * @param integer $project_id
+ * @param integer $task_id
+ * @param integer $position
+ * @param integer $swimlane_id
+ * @param integer $original_column_id
+ * @param integer $new_column_id
+ * @return boolean
+ */
+ private function saveColumnChange($project_id, $task_id, $position, $swimlane_id, $original_column_id, $new_column_id)
+ {
+ $this->db->startTransaction();
+ $r1 = $this->saveTaskPositions($project_id, $task_id, 0, $original_column_id, $swimlane_id);
+ $r2 = $this->saveTaskPositions($project_id, $task_id, $position, $new_column_id, $swimlane_id);
+ $this->db->closeTransaction();
- // We put our task to the new position
- if ($task_id) {
- array_splice($columns[$column_id], $position - 1, 0, $task_id);
- }
+ return $r1 && $r2;
+ }
+
+ /**
+ * Move a task to another position in the same column
+ *
+ * @access private
+ * @param integer $project_id
+ * @param integer $task_id
+ * @param integer $position
+ * @param integer $column_id
+ * @param integer $swimlane_id
+ * @return boolean
+ */
+ private function savePositionChange($project_id, $task_id, $position, $column_id, $swimlane_id)
+ {
+ $this->db->startTransaction();
+ $result = $this->saveTaskPositions($project_id, $task_id, $position, $column_id, $swimlane_id);
+ $this->db->closeTransaction();
- return $columns;
+ return $result;
}
/**
- * Save task positions
+ * Save all task positions for one column
*
* @access private
- * @param array $columns Sorted tasks
- * @param integer $swimlane_id Swimlane id
+ * @param integer $project_id
+ * @param integer $task_id
+ * @param integer $position
+ * @param integer $column_id
+ * @param integer $swimlane_id
* @return boolean
*/
- private function savePositions(array $columns, $swimlane_id)
+ private function saveTaskPositions($project_id, $task_id, $position, $column_id, $swimlane_id)
{
- return $this->db->transaction(function ($db) use ($columns, $swimlane_id) {
+ $tasks_ids = $this->db->table(Task::TABLE)
+ ->eq('is_active', 1)
+ ->eq('swimlane_id', $swimlane_id)
+ ->eq('project_id', $project_id)
+ ->eq('column_id', $column_id)
+ ->neq('id', $task_id)
+ ->asc('position')
+ ->asc('id')
+ ->findAllByColumn('id');
+
+ $offset = 1;
+
+ foreach ($tasks_ids as $current_task_id) {
+
+ // Insert the new task
+ if ($position == $offset) {
+ if (! $this->saveTaskPosition($task_id, $offset, $column_id, $swimlane_id)) {
+ return false;
+ }
+ $offset++;
+ }
- foreach ($columns as $column_id => $column) {
+ // Rewrite other tasks position
+ if (! $this->saveTaskPosition($current_task_id, $offset, $column_id, $swimlane_id)) {
+ return false;
+ }
- $position = 1;
+ $offset++;
+ }
- foreach ($column as $task_id) {
+ // Insert the new task at the bottom and normalize bad position
+ if ($position >= $offset && ! $this->saveTaskPosition($task_id, $offset, $column_id, $swimlane_id)) {
+ return false;
+ }
- $result = $db->table(Task::TABLE)->eq('id', $task_id)->update(array(
- 'position' => $position,
- 'column_id' => $column_id,
- 'swimlane_id' => $swimlane_id,
- ));
+ return true;
+ }
- if (! $result) {
- return false;
- }
+ /**
+ * Save new task position
+ *
+ * @access private
+ * @param integer $task_id
+ * @param integer $position
+ * @param integer $column_id
+ * @param integer $swimlane_id
+ * @return boolean
+ */
+ private function saveTaskPosition($task_id, $position, $column_id, $swimlane_id)
+ {
+ $result = $this->db->table(Task::TABLE)->eq('id', $task_id)->update(array(
+ 'position' => $position,
+ 'column_id' => $column_id,
+ 'swimlane_id' => $swimlane_id,
+ ));
+
+ if (! $result) {
+ $this->db->cancelTransaction();
+ return false;
+ }
- $position++;
- }
- }
- });
+ return true;
}
/**
@@ -165,26 +232,4 @@ class TaskPosition extends Base
$this->container['dispatcher']->dispatch(Task::EVENT_MOVE_POSITION, new TaskEvent($event_data));
}
}
-
- /**
- * Calculate the new position of all tasks
- *
- * @access private
- * @param integer $project_id Project id
- * @param integer $task_id Task id
- * @param integer $column_id Column id
- * @param integer $position Position (must be >= 1)
- * @param integer $swimlane_id Swimlane id
- * @return boolean
- */
- private function calculateAndSave($project_id, $task_id, $column_id, $position, $swimlane_id)
- {
- $positions = $this->calculatePositions($project_id, $task_id, $column_id, $position, $swimlane_id);
-
- if ($positions === false || ! $this->savePositions($positions, $swimlane_id)) {
- return false;
- }
-
- return true;
- }
}
diff --git a/app/Model/TaskValidator.php b/app/Model/TaskValidator.php
index ec1383ad..95b8a26c 100644
--- a/app/Model/TaskValidator.php
+++ b/app/Model/TaskValidator.php
@@ -39,7 +39,7 @@ class TaskValidator extends Base
new Validators\Integer('recurrence_status', t('This value must be an integer')),
new Validators\MaxLength('title', t('The maximum length is %d characters', 200), 200),
new Validators\Date('date_due', t('Invalid date'), $this->dateParser->getDateFormats()),
- new Validators\Date('date_started', t('Invalid date'), $this->dateParser->getDateFormats()),
+ new Validators\Date('date_started', t('Invalid date'), $this->dateParser->getAllFormats()),
new Validators\Numeric('time_spent', t('This value must be numeric')),
new Validators\Numeric('time_estimated', t('This value must be numeric')),
);
diff --git a/app/Model/Transition.php b/app/Model/Transition.php
index cb759e4a..ac3fba54 100644
--- a/app/Model/Transition.php
+++ b/app/Model/Transition.php
@@ -39,6 +39,22 @@ class Transition extends Base
}
/**
+ * Get time spent by task for each column
+ *
+ * @access public
+ * @param integer $task_id
+ * @return array
+ */
+ public function getTimeSpentByTask($task_id)
+ {
+ return $this->db
+ ->hashtable(self::TABLE)
+ ->groupBy('src_column_id')
+ ->eq('task_id', $task_id)
+ ->getAll('src_column_id', 'SUM(time_spent) AS time_spent');
+ }
+
+ /**
* Get all transitions by task
*
* @access public
diff --git a/app/Model/UserSession.php b/app/Model/UserSession.php
index 6de4a182..44a9c2a2 100644
--- a/app/Model/UserSession.php
+++ b/app/Model/UserSession.php
@@ -118,4 +118,28 @@ class UserSession extends Base
{
$_SESSION['filters'][$project_id] = $filters;
}
+
+ /**
+ * Is board collapsed or expanded
+ *
+ * @access public
+ * @param integer $project_id
+ * @return boolean
+ */
+ public function isBoardCollapsed($project_id)
+ {
+ return ! empty($_SESSION['board_collapsed'][$project_id]) ? $_SESSION['board_collapsed'][$project_id] : false;
+ }
+
+ /**
+ * Set board display mode
+ *
+ * @access public
+ * @param integer $project_id
+ * @param boolean $collapsed
+ */
+ public function setBoardDisplayMode($project_id, $collapsed)
+ {
+ $_SESSION['board_collapsed'][$project_id] = $collapsed;
+ }
}
diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php
index 0c932104..769a7425 100644
--- a/app/Schema/Mysql.php
+++ b/app/Schema/Mysql.php
@@ -6,7 +6,32 @@ use PDO;
use Core\Security;
use Model\Link;
-const VERSION = 77;
+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)
+{
+ $pdo->exec("ALTER TABLE project_integrations ADD COLUMN slack_webhook_channel VARCHAR(255) DEFAULT ''");
+ $pdo->exec("INSERT INTO settings VALUES ('integration_slack_webhook_channel', '')");
+}
function version_77($pdo)
{
diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php
index a3309068..fca87766 100644
--- a/app/Schema/Postgres.php
+++ b/app/Schema/Postgres.php
@@ -6,7 +6,31 @@ use PDO;
use Core\Security;
use Model\Link;
-const VERSION = 57;
+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)
+{
+ $pdo->exec("ALTER TABLE project_integrations ADD COLUMN slack_webhook_channel VARCHAR(255) DEFAULT ''");
+ $pdo->exec("INSERT INTO settings VALUES ('integration_slack_webhook_channel', '')");
+}
function version_57($pdo)
{
diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php
index c3bbbac9..9981e72f 100644
--- a/app/Schema/Sqlite.php
+++ b/app/Schema/Sqlite.php
@@ -6,7 +6,31 @@ use Core\Security;
use PDO;
use Model\Link;
-const VERSION = 73;
+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)
+{
+ $pdo->exec("ALTER TABLE project_integrations ADD COLUMN slack_webhook_channel TEXT DEFAULT ''");
+ $pdo->exec("INSERT INTO settings VALUES ('integration_slack_webhook_channel', '')");
+}
function version_73($pdo)
{
diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php
index 1fa0d0ef..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',
@@ -42,6 +43,7 @@ class ClassProvider implements ServiceProviderInterface
'SubtaskTimeTracking',
'Swimlane',
'Task',
+ 'TaskAnalytic',
'TaskCreation',
'TaskDuplication',
'TaskExport',
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/task/activity.php b/app/Template/activity/task.php
index cc4aad03..cc4aad03 100644
--- a/app/Template/task/activity.php
+++ b/app/Template/activity/task.php
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 @@
+<div class="page-header">
+ <h2><?= t('Average time spent into each column') ?></h2>
+</div>
+
+<?php if (empty($metrics)): ?>
+ <p class="alert"><?= t('Not enough data to show the graph.') ?></p>
+<?php else: ?>
+ <section id="analytic-avg-time-column">
+
+ <div id="chart" data-metrics='<?= json_encode($metrics) ?>' data-label="<?= t('Average time spent') ?>"></div>
+
+ <table class="table-stripped">
+ <tr>
+ <th><?= t('Column') ?></th>
+ <th><?= t('Average time spent') ?></th>
+ </tr>
+ <?php foreach ($metrics as $column): ?>
+ <tr>
+ <td><?= $this->e($column['title']) ?></td>
+ <td><?= $this->dt->duration($column['average']) ?></td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+
+ <p class="alert alert-info">
+ <?= t('This chart show the average time spent into each column for the last %d tasks.', 1000) ?>
+ </p>
+ </section>
+<?php endif ?>
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 @@
+<div class="page-header">
+ <h2><?= t('Average Lead and Cycle time') ?></h2>
+</div>
+
+<div class="listing">
+ <ul>
+ <li><?= t('Average lead time: ').'<strong>'.$this->dt->duration($average['avg_lead_time']) ?></strong></li>
+ <li><?= t('Average cycle time: ').'<strong>'.$this->dt->duration($average['avg_cycle_time']) ?></strong></li>
+ </ul>
+</div>
+
+<?php if (empty($metrics)): ?>
+ <p class="alert"><?= t('Not enough data to show the graph.') ?></p>
+<?php else: ?>
+ <section id="analytic-lead-cycle-time">
+
+ <div id="chart" data-metrics='<?= json_encode($metrics) ?>' data-label-cycle="<?= t('Cycle Time') ?>" data-label-lead="<?= t('Lead Time') ?>"></div>
+
+ <form method="post" class="form-inline" action="<?= $this->url->href('analytic', 'leadAndCycleTime', array('project_id' => $project['id'])) ?>" autocomplete="off">
+
+ <?= $this->form->csrf() ?>
+
+ <div class="form-inline-group">
+ <?= $this->form->label(t('Start Date'), 'from') ?>
+ <?= $this->form->text('from', $values, array(), array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
+ </div>
+
+ <div class="form-inline-group">
+ <?= $this->form->label(t('End Date'), 'to') ?>
+ <?= $this->form->text('to', $values, array(), array('required', 'placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
+ </div>
+
+ <div class="form-inline-group">
+ <input type="submit" value="<?= t('Execute') ?>" class="btn btn-blue"/>
+ </div>
+ </form>
+
+ <p class="alert alert-info">
+ <?= t('This chart show the average lead and cycle time for the last %d tasks over the time.', 1000) ?>
+ </p>
+ </section>
+<?php endif ?>
diff --git a/app/Template/analytic/sidebar.php b/app/Template/analytic/sidebar.php
index 2d1a7c96..de03bdf8 100644
--- a/app/Template/analytic/sidebar.php
+++ b/app/Template/analytic/sidebar.php
@@ -13,5 +13,11 @@
<li>
<?= $this->url->link(t('Burndown chart'), 'analytic', 'burndown', array('project_id' => $project['id'])) ?>
</li>
+ <li>
+ <?= $this->url->link(t('Average time into each column'), 'analytic', 'averageTimeByColumn', array('project_id' => $project['id'])) ?>
+ </li>
+ <li>
+ <?= $this->url->link(t('Lead and cycle time'), 'analytic', 'leadAndCycleTime', array('project_id' => $project['id'])) ?>
+ </li>
</ul>
</div> \ No newline at end of file
diff --git a/app/Template/board/task_private.php b/app/Template/board/task_private.php
index 87121f2c..7eaff580 100644
--- a/app/Template/board/task_private.php
+++ b/app/Template/board/task_private.php
@@ -10,53 +10,55 @@
<?= $this->render('board/task_menu', array('task' => $task)) ?>
- <div class="task-board-collapsed" style="display: none">
- <?php if (! empty($task['assignee_username'])): ?>
- <span title="<?= $this->e($task['assignee_name'] ?: $task['assignee_username']) ?>">
- <?= $this->e($this->user->getInitials($task['assignee_name'] ?: $task['assignee_username'])) ?>
- </span> -
- <?php endif ?>
- <span class="tooltip" title="<?= $this->e($task['title']) ?>"
- <?= $this->url->link($this->e($task['title']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'task-board-collapsed-title') ?>
- </span>
- </div>
+ <?php if ($this->board->isCollapsed($project['id'])): ?>
+ <div class="task-board-collapsed">
+ <?php if (! empty($task['assignee_username'])): ?>
+ <span title="<?= $this->e($task['assignee_name'] ?: $task['assignee_username']) ?>">
+ <?= $this->e($this->user->getInitials($task['assignee_name'] ?: $task['assignee_username'])) ?>
+ </span> -
+ <?php endif ?>
+ <span class="tooltip" title="<?= $this->e($task['title']) ?>"
+ <?= $this->url->link($this->e($task['title']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'task-board-collapsed-title') ?>
+ </span>
+ </div>
+ <?php else: ?>
+ <div class="task-board-expanded">
- <div class="task-board-expanded">
+ <?php if ($task['reference']): ?>
+ <span class="task-board-reference" title="<?= t('Reference') ?>">
+ (<?= $task['reference'] ?>)
+ </span>
+ <?php endif ?>
- <?php if ($task['reference']): ?>
- <span class="task-board-reference" title="<?= t('Reference') ?>">
- (<?= $task['reference'] ?>)
- </span>
- <?php endif ?>
+ <span class="task-board-user <?= $this->user->isCurrentUser($task['owner_id']) ? 'task-board-current-user' : '' ?>">
+ <?= $this->url->link(
+ (! empty($task['owner_id']) ? ($task['assignee_name'] ?: $task['assignee_username']) : t('Nobody assigned')),
+ 'board',
+ 'changeAssignee',
+ array('task_id' => $task['id'], 'project_id' => $task['project_id']),
+ false,
+ 'task-board-popover',
+ t('Change assignee')
+ ) ?>
+ </span>
- <span class="task-board-user <?= $this->user->isCurrentUser($task['owner_id']) ? 'task-board-current-user' : '' ?>">
- <?= $this->url->link(
- (! empty($task['owner_id']) ? ($task['assignee_name'] ?: $task['assignee_username']) : t('Nobody assigned')),
- 'board',
- 'changeAssignee',
- array('task_id' => $task['id'], 'project_id' => $task['project_id']),
- false,
- 'task-board-popover',
- t('Change assignee')
- ) ?>
- </span>
+ <?php if ($task['is_active'] == 1): ?>
+ <div class="task-board-days">
+ <span title="<?= t('Task age in days')?>" class="task-days-age"><?= $this->dt->age($task['date_creation']) ?></span>
+ <span title="<?= t('Days in this column')?>" class="task-days-incolumn"><?= $this->dt->age($task['date_moved']) ?></span>
+ </div>
+ <?php else: ?>
+ <div class="task-board-closed"><i class="fa fa-ban fa-fw"></i><?= t('Closed') ?></div>
+ <?php endif ?>
- <?php if ($task['is_active'] == 1): ?>
- <div class="task-board-days">
- <span title="<?= t('Task age in days')?>" class="task-days-age"><?= $this->datetime->age($task['date_creation']) ?></span>
- <span title="<?= t('Days in this column')?>" class="task-days-incolumn"><?= $this->datetime->age($task['date_moved']) ?></span>
- </div>
- <?php else: ?>
- <div class="task-board-closed"><i class="fa fa-ban fa-fw"></i><?= t('Closed') ?></div>
- <?php endif ?>
+ <div class="task-board-title">
+ <?= $this->url->link($this->e($task['title']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, '', t('View this task')) ?>
+ </div>
- <div class="task-board-title">
- <?= $this->url->link($this->e($task['title']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, '', t('View this task')) ?>
+ <?= $this->render('board/task_footer', array(
+ 'task' => $task,
+ 'not_editable' => $not_editable,
+ )) ?>
</div>
-
- <?= $this->render('board/task_footer', array(
- 'task' => $task,
- 'not_editable' => $not_editable,
- )) ?>
- </div>
+ <?php endif ?>
</div>
diff --git a/app/Template/config/integrations.php b/app/Template/config/integrations.php
index a1299806..9c80b499 100644
--- a/app/Template/config/integrations.php
+++ b/app/Template/config/integrations.php
@@ -77,6 +77,8 @@
<?= $this->form->label(t('Webhook URL'), 'integration_slack_webhook_url') ?>
<?= $this->form->text('integration_slack_webhook_url', $values, $errors) ?>
+ <?= $this->form->label(t('Channel/Group/User (Optional)'), 'integration_slack_webhook_channel') ?>
+ <?= $this->form->text('integration_slack_webhook_channel', $values, $errors) ?>
<p class="form-help"><a href="http://kanboard.net/documentation/slack" target="_blank"><?= t('Help on Slack integration') ?></a></p>
</div>
diff --git a/app/Template/layout.php b/app/Template/layout.php
index d804d3d5..a9f1cbc3 100644
--- a/app/Template/layout.php
+++ b/app/Template/layout.php
@@ -48,7 +48,7 @@
<ul>
<?php if (isset($board_selector) && ! empty($board_selector)): ?>
<li>
- <select id="board-selector" tabindex=="-1" data-notfound="<?= t('No results match:') ?>" data-placeholder="<?= t('Display another project') ?>" data-board-url="<?= $this->url->href('board', 'show', array('project_id' => 'PROJECT_ID')) ?>">
+ <select id="board-selector" tabindex="-1" data-notfound="<?= t('No results match:') ?>" data-placeholder="<?= t('Display another project') ?>" data-board-url="<?= $this->url->href('board', 'show', array('project_id' => 'PROJECT_ID')) ?>">
<option value=""></option>
<?php foreach($board_selector as $board_id => $board_name): ?>
<option value="<?= $board_id ?>"><?= $this->e($board_name) ?></option>
diff --git a/app/Template/project/filters.php b/app/Template/project/filters.php
index 396baadf..3beb2f44 100644
--- a/app/Template/project/filters.php
+++ b/app/Template/project/filters.php
@@ -5,12 +5,13 @@
<ul>
<?php if (isset($is_board)): ?>
<li>
- <span class="filter-collapse">
- <i class="fa fa-compress fa-fw"></i> <a href="#" class="filter-collapse-link" title="<?= t('Keyboard shortcut: "%s"', 's') ?>"><?= t('Collapse tasks') ?></a>
- </span>
- <span class="filter-expand" style="display: none">
- <i class="fa fa-expand fa-fw"></i> <a href="#" class="filter-expand-link" title="<?= t('Keyboard shortcut: "%s"', 's') ?>"><?= t('Expand tasks') ?></a>
- </span>
+ <?php if ($this->board->isCollapsed($project['id'])): ?>
+ <i class="fa fa-expand fa-fw"></i>
+ <?= $this->url->link(t('Expand tasks'), 'board', 'expand', array('project_id' => $project['id']), false, 'board-display-mode', t('Keyboard shortcut: "%s"', 's')) ?>
+ <?php else: ?>
+ <i class="fa fa-compress fa-fw"></i>
+ <?= $this->url->link(t('Collapse tasks'), 'board', 'collapse', array('project_id' => $project['id']), false, 'board-display-mode', t('Keyboard shortcut: "%s"', 's')) ?>
+ <?php endif ?>
</li>
<li>
<span class="filter-compact">
diff --git a/app/Template/project/integrations.php b/app/Template/project/integrations.php
index 698e438c..445e7bfb 100644
--- a/app/Template/project/integrations.php
+++ b/app/Template/project/integrations.php
@@ -85,6 +85,8 @@
<?= $this->form->label(t('Webhook URL'), 'slack_webhook_url') ?>
<?= $this->form->text('slack_webhook_url', $values, $errors) ?>
+ <?= $this->form->label(t('Channel/Group/User (Optional)'), 'slack_webhook_channel') ?>
+ <?= $this->form->text('slack_webhook_channel', $values, $errors) ?>
<p class="form-help"><a href="http://kanboard.net/documentation/slack" target="_blank"><?= t('Help on Slack integration') ?></a></p>
diff --git a/app/Template/subtask/show.php b/app/Template/subtask/show.php
index b91e830f..cc82a74e 100644
--- a/app/Template/subtask/show.php
+++ b/app/Template/subtask/show.php
@@ -48,7 +48,7 @@
<?php if ($subtask['is_timer_started']): ?>
<i class="fa fa-pause"></i>
<?= $this->url->link(t('Stop timer'), 'timer', 'subtask', array('timer' => 'stop', 'project_id' => $task['project_id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'])) ?>
- (<?= $this->datetime->age($subtask['timer_start_date']) ?>)
+ (<?= $this->dt->age($subtask['timer_start_date']) ?>)
<?php else: ?>
<i class="fa fa-play-circle-o"></i>
<?= $this->url->link(t('Start timer'), 'timer', 'subtask', array('timer' => 'start', 'project_id' => $task['project_id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'])) ?>
diff --git a/app/Template/task/analytics.php b/app/Template/task/analytics.php
new file mode 100644
index 00000000..3b1d2855
--- /dev/null
+++ b/app/Template/task/analytics.php
@@ -0,0 +1,36 @@
+<div class="page-header">
+ <h2><?= t('Analytics') ?></h2>
+</div>
+
+<div class="listing">
+ <ul>
+ <li><?= t('Lead time: ').'<strong>'.$this->dt->duration($lead_time) ?></strong></li>
+ <li><?= t('Cycle time: ').'<strong>'.$this->dt->duration($cycle_time) ?></strong></li>
+ </ul>
+</div>
+
+<h3 id="analytic-task-time-column"><?= t('Time spent into each column') ?></h3>
+<div id="chart" data-metrics='<?= json_encode($time_spent_columns) ?>' data-label="<?= t('Time spent') ?>"></div>
+<table class="table-stripped">
+ <tr>
+ <th><?= t('Column') ?></th>
+ <th><?= t('Time spent') ?></th>
+ </tr>
+ <?php foreach ($time_spent_columns as $column): ?>
+ <tr>
+ <td><?= $this->e($column['title']) ?></td>
+ <td><?= $this->dt->duration($column['time_spent']) ?></td>
+ </tr>
+ <?php endforeach ?>
+</table>
+
+<div class="alert alert-info">
+ <ul>
+ <li><?= t('The lead time is the duration between the task creation and the completion.') ?></li>
+ <li><?= t('The cycle time is the duration between the start date and the completion.') ?></li>
+ <li><?= t('If the task is not closed the current time is used instead of the completion date.') ?></li>
+ </ul>
+</div>
+
+<?= $this->asset->js('assets/js/vendor/d3.v3.min.js') ?>
+<?= $this->asset->js('assets/js/vendor/c3.min.js') ?> \ No newline at end of file
diff --git a/app/Template/task/layout.php b/app/Template/task/layout.php
index ddce4bce..bbccf177 100644
--- a/app/Template/task/layout.php
+++ b/app/Template/task/layout.php
@@ -3,7 +3,7 @@
<ul>
<li>
<i class="fa fa-th fa-fw"></i>
- <?= $this->url->link(t('Back to the board'), 'board', 'show', array('project_id' => $task['project_id']), false, '', '', false, 'swimlane-'.$task['swimlane_id']) ?>
+ <?= $this->url->link(t('Back to the board'), 'board', 'show', array('project_id' => $task['project_id']), false, '', '', false, $task['swimlane_id'] != 0 ? 'swimlane-'.$task['swimlane_id'] : '') ?>
</li>
<li>
<i class="fa fa-calendar fa-fw"></i>
diff --git a/app/Template/task/sidebar.php b/app/Template/task/sidebar.php
index bb137ac9..8b0f3c6e 100644
--- a/app/Template/task/sidebar.php
+++ b/app/Template/task/sidebar.php
@@ -5,11 +5,14 @@
<?= $this->url->link(t('Summary'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
<li>
- <?= $this->url->link(t('Activity stream'), 'task', 'activites', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ <?= $this->url->link(t('Activity stream'), 'activity', 'task', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
<li>
<?= $this->url->link(t('Transitions'), 'task', 'transitions', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
+ <li>
+ <?= $this->url->link(t('Analytics'), 'task', 'analytics', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </li>
<?php if ($task['time_estimated'] > 0 || $task['time_spent'] > 0): ?>
<li>
<?= $this->url->link(t('Time tracking'), 'task', 'timesheet', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
diff --git a/app/Template/task/time.php b/app/Template/task/time.php
index 6682a08d..90407d7a 100644
--- a/app/Template/task/time.php
+++ b/app/Template/task/time.php
@@ -1,9 +1,14 @@
<form method="post" action="<?= $this->url->href('task', 'time', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" class="form-inline task-time-form" autocomplete="off">
+
+ <?php if (empty($values['date_started'])): ?>
+ <?= $this->url->link('<i class="fa fa-play"></i>', 'task', 'start', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'task-show-start-link', t('Set automatically the start date')) ?>
+ <?php endif ?>
+
<?= $this->form->csrf() ?>
<?= $this->form->hidden('id', $values) ?>
<?= $this->form->label(t('Start date'), 'date_started') ?>
- <?= $this->form->text('date_started', $values, array(), array('placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
+ <?= $this->form->text('date_started', $values, array(), array('placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-datetime') ?>
<?= $this->form->label(t('Time estimated'), 'time_estimated') ?>
<?= $this->form->numeric('time_estimated', $values, array(), array('placeholder="'.t('hours').'"')) ?>
diff --git a/app/Template/task/transitions.php b/app/Template/task/transitions.php
index 6455fd66..2ca2387f 100644
--- a/app/Template/task/transitions.php
+++ b/app/Template/task/transitions.php
@@ -19,7 +19,7 @@
<td><?= $this->e($transition['src_column']) ?></td>
<td><?= $this->e($transition['dst_column']) ?></td>
<td><?= $this->url->link($this->e($transition['name'] ?: $transition['username']), 'user', 'show', array('user_id' => $transition['user_id'])) ?></td>
- <td><?= n(round($transition['time_spent'] / 3600, 2)).' '.t('hours') ?></td>
+ <td><?= $this->dt->duration($transition['time_spent']) ?></td>
</tr>
<?php endforeach ?>
</table>
diff --git a/app/Template/timetable_day/index.php b/app/Template/timetable_day/index.php
index d2877816..386ceec2 100644
--- a/app/Template/timetable_day/index.php
+++ b/app/Template/timetable_day/index.php
@@ -30,10 +30,10 @@
<?= $this->form->csrf() ?>
<?= $this->form->label(t('Start time'), 'start') ?>
- <?= $this->form->select('start', $this->datetime->getDayHours(), $values, $errors) ?>
+ <?= $this->form->select('start', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('End time'), 'end') ?>
- <?= $this->form->select('end', $this->datetime->getDayHours(), $values, $errors) ?>
+ <?= $this->form->select('end', $this->dt->getDayHours(), $values, $errors) ?>
<div class="form-actions">
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
diff --git a/app/Template/timetable_extra/index.php b/app/Template/timetable_extra/index.php
index d3224ae6..e9982335 100644
--- a/app/Template/timetable_extra/index.php
+++ b/app/Template/timetable_extra/index.php
@@ -42,10 +42,10 @@
<?= $this->form->checkbox('all_day', t('All day'), 1) ?>
<?= $this->form->label(t('Start time'), 'start') ?>
- <?= $this->form->select('start', $this->datetime->getDayHours(), $values, $errors) ?>
+ <?= $this->form->select('start', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('End time'), 'end') ?>
- <?= $this->form->select('end', $this->datetime->getDayHours(), $values, $errors) ?>
+ <?= $this->form->select('end', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('Comment'), 'comment') ?>
<?= $this->form->text('comment', $values, $errors) ?>
diff --git a/app/Template/timetable_off/index.php b/app/Template/timetable_off/index.php
index 75e02dbd..615c2b8d 100644
--- a/app/Template/timetable_off/index.php
+++ b/app/Template/timetable_off/index.php
@@ -42,10 +42,10 @@
<?= $this->form->checkbox('all_day', t('All day'), 1) ?>
<?= $this->form->label(t('Start time'), 'start') ?>
- <?= $this->form->select('start', $this->datetime->getDayHours(), $values, $errors) ?>
+ <?= $this->form->select('start', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('End time'), 'end') ?>
- <?= $this->form->select('end', $this->datetime->getDayHours(), $values, $errors) ?>
+ <?= $this->form->select('end', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('Comment'), 'comment') ?>
<?= $this->form->text('comment', $values, $errors) ?>
diff --git a/app/Template/timetable_week/index.php b/app/Template/timetable_week/index.php
index 552e9302..d58c6cfb 100644
--- a/app/Template/timetable_week/index.php
+++ b/app/Template/timetable_week/index.php
@@ -13,7 +13,7 @@
</tr>
<?php foreach ($timetable as $slot): ?>
<tr>
- <td><?= $this->datetime->getWeekDay($slot['day']) ?></td>
+ <td><?= $this->dt->getWeekDay($slot['day']) ?></td>
<td><?= $slot['start'] ?></td>
<td><?= $slot['end'] ?></td>
<td>
@@ -32,13 +32,13 @@
<?= $this->form->csrf() ?>
<?= $this->form->label(t('Day'), 'day') ?>
- <?= $this->form->select('day', $this->datetime->getWeekDays(), $values, $errors) ?>
+ <?= $this->form->select('day', $this->dt->getWeekDays(), $values, $errors) ?>
<?= $this->form->label(t('Start time'), 'start') ?>
- <?= $this->form->select('start', $this->datetime->getDayHours(), $values, $errors) ?>
+ <?= $this->form->select('start', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('End time'), 'end') ?>
- <?= $this->form->select('end', $this->datetime->getDayHours(), $values, $errors) ?>
+ <?= $this->form->select('end', $this->dt->getDayHours(), $values, $errors) ?>
<div class="form-actions">
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
diff --git a/app/common.php b/app/common.php
index d1659018..b5871673 100644
--- a/app/common.php
+++ b/app/common.php
@@ -1,6 +1,6 @@
<?php
-require 'vendor/autoload.php';
+require dirname(__DIR__) . '/vendor/autoload.php';
// Automatically parse environment configuration (Heroku)
if (getenv('DATABASE_URL')) {