only imports conflicted
This commit is contained in:
Lesstat
2015-07-11 11:44:26 +02:00
95 changed files with 2041 additions and 794 deletions

View File

@@ -12,17 +12,17 @@ class Project extends Base
{ {
public function getProjectById($project_id) public function getProjectById($project_id)
{ {
return $this->project->getById($project_id); return $this->formatProject($this->project->getById($project_id));
} }
public function getProjectByName($name) public function getProjectByName($name)
{ {
return $this->project->getByName($name); return $this->formatProject($this->project->getByName($name));
} }
public function getAllProjects() public function getAllProjects()
{ {
return $this->project->getAll(); return $this->formatProjects($this->project->getAll());
} }
public function removeProject($project_id) public function removeProject($project_id)
@@ -82,4 +82,28 @@ class Project extends Base
list($valid,) = $this->project->validateModification($values); list($valid,) = $this->project->validateModification($values);
return $valid && $this->project->update($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;
}
} }

View File

@@ -14,17 +14,17 @@ class Task extends Base
{ {
public function getTask($task_id) 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) 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) 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() public function getOverdueTasks()
@@ -115,4 +115,24 @@ class Task extends Base
list($valid) = $this->taskValidator->validateApiModification($values); list($valid) = $this->taskValidator->validateApiModification($values);
return $valid && $this->taskModification->update($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;
}
} }

View File

@@ -118,31 +118,6 @@ class RememberMe extends Base
return false; return false;
} }
/**
* 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 * Remove a session record
* *
@@ -197,9 +172,10 @@ class RememberMe extends Base
$this->cleanup($user_id); $this->cleanup($user_id);
$this->db $this
->table(self::TABLE) ->db
->insert(array( ->table(self::TABLE)
->insert(array(
'user_id' => $user_id, 'user_id' => $user_id,
'ip' => $ip, 'ip' => $ip,
'user_agent' => $user_agent, 'user_agent' => $user_agent,
@@ -207,7 +183,7 @@ class RememberMe extends Base
'sequence' => $sequence, 'sequence' => $sequence,
'expiration' => $expiration, 'expiration' => $expiration,
'date_creation' => time(), 'date_creation' => time(),
)); ));
return array( return array(
'token' => $token, 'token' => $token,

View File

@@ -11,16 +11,17 @@ use Symfony\Component\Console\Command\Command;
* @package console * @package console
* @author Frederic Guillot * @author Frederic Guillot
* *
* @property \Model\Notification $notification * @property \Model\Notification $notification
* @property \Model\Project $project * @property \Model\Project $project
* @property \Model\ProjectPermission $projectPermission * @property \Model\ProjectPermission $projectPermission
* @property \Model\ProjectAnalytic $projectAnalytic * @property \Model\ProjectAnalytic $projectAnalytic
* @property \Model\ProjectDailySummary $projectDailySummary * @property \Model\ProjectDailyColumnStats $projectDailyColumnStats
* @property \Model\SubtaskExport $subtaskExport * @property \Model\ProjectDailyStats $projectDailyColumnStats
* @property \Model\Task $task * @property \Model\SubtaskExport $subtaskExport
* @property \Model\TaskExport $taskExport * @property \Model\Task $task
* @property \Model\TaskFinder $taskFinder * @property \Model\TaskExport $taskExport
* @property \Model\Transition $transition * @property \Model\TaskFinder $taskFinder
* @property \Model\Transition $transition
*/ */
abstract class Base extends Command abstract class Base extends Command
{ {

View File

@@ -7,13 +7,13 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
class ProjectDailySummaryExport extends Base class ProjectDailyColumnStatsExport extends Base
{ {
protected function configure() protected function configure()
{ {
$this $this
->setName('export:daily-project-summary') ->setName('export:daily-project-column-stats')
->setDescription('Daily project summary CSV export (number of tasks per column and per day)') ->setDescription('Daily project column stats CSV export (number of tasks per column and per day)')
->addArgument('project_id', InputArgument::REQUIRED, 'Project id') ->addArgument('project_id', InputArgument::REQUIRED, 'Project id')
->addArgument('start_date', InputArgument::REQUIRED, 'Start date (YYYY-MM-DD)') ->addArgument('start_date', InputArgument::REQUIRED, 'Start date (YYYY-MM-DD)')
->addArgument('end_date', InputArgument::REQUIRED, 'End 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) protected function execute(InputInterface $input, OutputInterface $output)
{ {
$data = $this->projectDailySummary->getAggregatedMetrics( $data = $this->projectDailyColumnStats->getAggregatedMetrics(
$input->getArgument('project_id'), $input->getArgument('project_id'),
$input->getArgument('start_date'), $input->getArgument('start_date'),
$input->getArgument('end_date') $input->getArgument('end_date')

View File

@@ -6,13 +6,13 @@ use Model\Project;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
class ProjectDailySummaryCalculation extends Base class ProjectDailyStatsCalculation extends Base
{ {
protected function configure() protected function configure()
{ {
$this $this
->setName('projects:daily-summary') ->setName('projects:daily-stats')
->setDescription('Calculate daily summary data for all projects'); ->setDescription('Calculate daily statistics for all projects');
} }
protected function execute(InputInterface $input, OutputInterface $output) protected function execute(InputInterface $input, OutputInterface $output)
@@ -21,7 +21,8 @@ class ProjectDailySummaryCalculation extends Base
foreach ($projects as $project) { foreach ($projects as $project) {
$output->writeln('Run calculation for '.$project['name']); $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'));
} }
} }
} }

View File

@@ -26,4 +26,20 @@ class Activity extends Base
'title' => t('%s\'s activity', $project['name']) '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']),
)));
}
} }

View File

@@ -3,7 +3,7 @@
namespace Controller; namespace Controller;
/** /**
* Project Anaytic controller * Project Analytic controller
* *
* @package controller * @package controller
* @author Frederic Guillot * @author Frederic Guillot
@@ -26,6 +26,56 @@ class Analytic extends Base
return $this->template->layout('analytic/layout', $params); return $this->template->layout('analytic/layout', $params);
} }
/**
* Show average Lead and Cycle time
*
* @access public
*/
public function leadAndCycleTime()
{
$project = $this->getProject();
$values = $this->request->getValues();
$this->projectDailyStats->updateTotals($project['id'], date('Y-m-d'));
$from = $this->request->getStringParam('from', date('Y-m-d', strtotime('-1week')));
$to = $this->request->getStringParam('to', date('Y-m-d'));
if (! empty($values)) {
$from = $values['from'];
$to = $values['to'];
}
$this->response->html($this->layout('analytic/lead_cycle_time', array(
'values' => array(
'from' => $from,
'to' => $to,
),
'project' => $project,
'average' => $this->projectAnalytic->getAverageLeadAndCycleTime($project['id']),
'metrics' => $this->projectDailyStats->getRawMetrics($project['id'], $from, $to),
'date_format' => $this->config->get('application_date_format'),
'date_formats' => $this->dateParser->getAvailableFormats(),
'title' => t('Lead and Cycle time for "%s"', $project['name']),
)));
}
/**
* Show average time spent by column
*
* @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 * Show tasks distribution graph
* *
@@ -88,6 +138,8 @@ class Analytic extends Base
$project = $this->getProject(); $project = $this->getProject();
$values = $this->request->getValues(); $values = $this->request->getValues();
$this->projectDailyColumnStats->updateTotals($project['id'], date('Y-m-d'));
$from = $this->request->getStringParam('from', date('Y-m-d', strtotime('-1week'))); $from = $this->request->getStringParam('from', date('Y-m-d', strtotime('-1week')));
$to = $this->request->getStringParam('to', date('Y-m-d')); $to = $this->request->getStringParam('to', date('Y-m-d'));
@@ -96,7 +148,7 @@ class Analytic extends Base
$to = $values['to']; $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( $this->response->html($this->layout($template, array(
'values' => array( 'values' => array(
@@ -104,7 +156,7 @@ class Analytic extends Base
'to' => $to, 'to' => $to,
), ),
'display_graph' => $display_graph, '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, 'project' => $project,
'date_format' => $this->config->get('application_date_format'), 'date_format' => $this->config->get('application_date_format'),
'date_formats' => $this->dateParser->getAvailableFormats(), 'date_formats' => $this->dateParser->getAvailableFormats(),

View File

@@ -310,4 +310,28 @@ class Board extends Base
'recurrence_basedate_list' => $this->task->getRecurrenceBasedateList(), '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)));
}
} }

View File

@@ -70,7 +70,7 @@ class Export extends Base
*/ */
public function summary() 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'));
} }
/** /**

View File

@@ -78,8 +78,8 @@ class Ical extends Base
*/ */
private function renderCalendar(TaskFilter $filter, iCalendar $calendar) private function renderCalendar(TaskFilter $filter, iCalendar $calendar)
{ {
$start = $this->request->getStringParam('start', strtotime('-1 month')); $start = $this->request->getStringParam('start', strtotime('-2 month'));
$end = $this->request->getStringParam('end', strtotime('+2 months')); $end = $this->request->getStringParam('end', strtotime('+6 months'));
// Tasks // Tasks
if ($this->config->get('calendar_project_tasks', 'date_started') === 'date_creation') { if ($this->config->get('calendar_project_tasks', 'date_started') === 'date_creation') {

View File

@@ -64,7 +64,7 @@ class Task extends Base
'time_spent' => $task['time_spent'] ?: '', '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( $this->response->html($this->taskLayout('task/show', array(
'project' => $this->project->getById($task['project_id']), 'project' => $this->project->getById($task['project_id']),
@@ -88,19 +88,20 @@ class Task extends Base
} }
/** /**
* Display task activities * Display task analytics
* *
* @access public * @access public
*/ */
public function activites() public function analytics()
{ {
$task = $this->getTask(); $task = $this->getTask();
$this->response->html($this->taskLayout('task/activity', array( $this->response->html($this->taskLayout('task/analytics', array(
'title' => $task['title'], 'title' => $task['title'],
'task' => $task, 'task' => $task,
'ajax' => $this->request->isAjax(), 'lead_time' => $this->taskAnalytic->getLeadTime($task),
'events' => $this->projectActivity->getTask($task['id']), 'cycle_time' => $this->taskAnalytic->getCycleTime($task),
'time_spent_columns' => $this->taskAnalytic->getTimeSpentByColumn($task),
))); )));
} }
@@ -151,7 +152,6 @@ class Task extends Base
{ {
$project = $this->getProject(); $project = $this->getProject();
$values = $this->request->getValues(); $values = $this->request->getValues();
$values['creator_id'] = $this->userSession->getId();
list($valid, $errors) = $this->taskValidator->validateCreation($values); list($valid, $errors) = $this->taskValidator->validateCreation($values);
@@ -618,4 +618,16 @@ class Task extends Base
'transitions' => $this->transition->getAllByTask($task['id']), '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'])));
}
} }

View File

@@ -48,7 +48,8 @@ use Pimple\Container;
* @property \Model\ProjectActivity $projectActivity * @property \Model\ProjectActivity $projectActivity
* @property \Model\ProjectAnalytic $projectAnalytic * @property \Model\ProjectAnalytic $projectAnalytic
* @property \Model\ProjectDuplication $projectDuplication * @property \Model\ProjectDuplication $projectDuplication
* @property \Model\ProjectDailySummary $projectDailySummary * @property \Model\ProjectDailyColumnStats $projectDailyColumnStats
* @property \Model\ProjectDailyStats $projectDailyStats
* @property \Model\ProjectIntegration $projectIntegration * @property \Model\ProjectIntegration $projectIntegration
* @property \Model\ProjectPermission $projectPermission * @property \Model\ProjectPermission $projectPermission
* @property \Model\Subtask $subtask * @property \Model\Subtask $subtask

View File

@@ -2,6 +2,8 @@
namespace Core; namespace Core;
use Pimple\Container;
/** /**
* Helper base class * Helper base class
* *
@@ -10,7 +12,7 @@ namespace Core;
* *
* @property \Helper\App $app * @property \Helper\App $app
* @property \Helper\Asset $asset * @property \Helper\Asset $asset
* @property \Helper\Datetime $datetime * @property \Helper\Dt $dt
* @property \Helper\File $file * @property \Helper\File $file
* @property \Helper\Form $form * @property \Helper\Form $form
* @property \Helper\Subtask $subtask * @property \Helper\Subtask $subtask
@@ -19,16 +21,34 @@ namespace Core;
* @property \Helper\Url $url * @property \Helper\Url $url
* @property \Helper\User $user * @property \Helper\User $user
*/ */
class Helper extends Base class Helper
{ {
/** /**
* Helper instances * Helper instances
* *
* @static
* @access private * @access private
* @var array * @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 * Load automatically helpers
@@ -39,12 +59,12 @@ class Helper extends Base
*/ */
public function __get($name) public function __get($name)
{ {
if (! isset(self::$helpers[$name])) { if (! isset($this->helpers[$name])) {
$class = '\Helper\\'.ucfirst($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];
} }
/** /**

24
app/Helper/Board.php Normal file
View File

@@ -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);
}
}

View File

@@ -2,14 +2,34 @@
namespace Helper; namespace Helper;
use DateTime;
/** /**
* DateTime helpers * DateTime helpers
* *
* @package helper * @package helper
* @author Frederic Guillot * @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. * Get the age of an item in quasi human readable format.
* It's in this format: <1h , NNh, NNd * It's in this format: <1h , NNh, NNd

View File

@@ -99,6 +99,10 @@ class Url extends \Core\Base
*/ */
public function server() public function server()
{ {
if (empty($_SERVER['SERVER_NAME'])) {
return 'http://localhost/';
}
$self = str_replace('\\', '/', dirname($_SERVER['PHP_SELF'])); $self = str_replace('\\', '/', dirname($_SERVER['PHP_SELF']));
$url = Request::isHTTPS() ? 'https://' : 'http://'; $url = Request::isHTTPS() ? 'https://' : 'http://';

View File

@@ -39,6 +39,25 @@ class SlackWebhook extends \Core\Base
return $options['slack_webhook_url']; return $options['slack_webhook_url'];
} }
/**
* 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 * Send message to the incoming Slack webhook
* *
@@ -69,6 +88,11 @@ class SlackWebhook extends \Core\Base
$payload['text'] .= '|'.t('view the task on Kanboard').'>'; $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); $this->httpClient->postJson($this->getWebhookUrl($project_id), $payload);
} }
} }

View File

@@ -20,15 +20,15 @@ return array(
'Red' => 'Röd', 'Red' => 'Röd',
'Orange' => 'Orange', 'Orange' => 'Orange',
'Grey' => 'Grå', 'Grey' => 'Grå',
// 'Brown' => '', 'Brown' => 'Brun',
// 'Deep Orange' => '', 'Deep Orange' => 'Mörkorange',
// 'Dark Grey' => '', 'Dark Grey' => 'Mörkgrå',
// 'Pink' => '', 'Pink' => 'Rosa',
// 'Teal' => '', 'Teal' => 'Grönblå',
// 'Cyan' => '', 'Cyan' => 'Cyan',
// 'Lime' => '', 'Lime' => 'Lime',
// 'Light Green' => '', 'Light Green' => 'Ljusgrön',
// 'Amber' => '', 'Amber' => 'Bärnsten',
'Save' => 'Spara', 'Save' => 'Spara',
'Login' => 'Login', 'Login' => 'Login',
'Official website:' => 'Officiell webbsida:', '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 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.', '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', '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', 'Transitions' => 'Övergångar',
'Executer' => 'Verkställare', 'Executer' => 'Verkställare',
'Time spent in the column' => 'Tid i kolumnen.', '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 for "%s"' => 'Burndown diagram för "%s"',
'Burndown chart' => 'Burndown diagram', '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).', 'This chart show the task complexity over the time (Work Remaining).' => 'Diagrammet visar uppgiftens svårighet över tid (återstående arbete).',
// 'Screenshot taken %s' => '', 'Screenshot taken %s' => 'Skärmdump tagen %s',
// 'Add a screenshot' => '', 'Add a screenshot' => 'Lägg till en skärmdump',
// 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '', '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.' => '', 'Screenshot uploaded successfully.' => 'Skärmdumpen laddades upp.',
'SEK - Swedish Krona' => 'SEK - Svensk Krona', 'SEK - Swedish Krona' => 'SEK - Svensk Krona',
// 'The project identifier is an optional alphanumeric code used to identify your project.' => '', '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' => '', 'Identifier' => 'Identifierare',
// 'Postmark (incoming emails)' => '', 'Postmark (incoming emails)' => 'Postmark (inkommande e-post)',
// 'Help on Postmark integration' => '', 'Help on Postmark integration' => 'Hjälp för Postmark integration',
// 'Mailgun (incoming emails)' => '', 'Mailgun (incoming emails)' => 'Mailgrun (inkommande e-post)',
// 'Help on Mailgun integration' => '', 'Help on Mailgun integration' => 'Hjälp för Mailgrun integration',
// 'Sendgrid (incoming emails)' => '', // 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '', 'Help on Sendgrid integration' => 'Hjälp för Sendgrid integration',
// 'Disable two factor authentication' => '', 'Disable two factor authentication' => 'Inaktivera två-faktors autentisering',
// 'Do you really want to disable the two factor authentication for this user: "%s"?' => '', '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' => '', 'Edit link' => 'Ändra länk',
// 'Start to type task title...' => '', 'Start to type task title...' => 'Börja skriv uppgiftstitel...',
// 'A task cannot be linked to itself' => '', 'A task cannot be linked to itself' => 'En uppgift kan inte länkas till sig själv',
// 'The exact same link already exists' => '', 'The exact same link already exists' => 'Länken existerar redan',
// 'Recurrent task is scheduled to be generated' => '', 'Recurrent task is scheduled to be generated' => 'Återkommande uppgift är schemalagd att genereras',
// 'Recurring information' => '', 'Recurring information' => 'Återkommande information',
// 'Score' => '', 'Score' => 'Poäng',
// 'The identifier must be unique' => '', 'The identifier must be unique' => 'Identifieraren måste vara unik',
// 'This linked task id doesn\'t exists' => '', 'This linked task id doesn\'t exists' => 'Denna länkade uppgifts id existerar inte',
// 'This value must be alphanumeric' => '', 'This value must be alphanumeric' => 'Värdet måste vara alfanumeriskt',
// 'Edit recurrence' => '', 'Edit recurrence' => 'Ändra återkommande',
// 'Generate recurrent task' => '', 'Generate recurrent task' => 'Generera återkommande uppgift',
// 'Trigger to generate recurrent task' => '', 'Trigger to generate recurrent task' => 'Aktivera att generera återkommande uppgift',
// 'Factor to calculate new due date' => '', 'Factor to calculate new due date' => 'Faktor för att beräkna nytt datum',
// 'Timeframe to calculate new due date' => '', 'Timeframe to calculate new due date' => 'Tidsram för att beräkna nytt datum',
// 'Base date to calculate new due date' => '', 'Base date to calculate new due date' => 'Basdatum för att beräkna nytt datum',
// 'Action date' => '', 'Action date' => 'Händelsedatum',
// 'Base date to calculate new due date: ' => '', 'Base date to calculate new due date: ' => 'Basdatum för att beräkna nytt förfallodatum: ',
// 'This task has created this child task: ' => '', 'This task has created this child task: ' => 'Uppgiften har skapat denna underliggande uppgift: ',
// 'Day(s)' => '', 'Day(s)' => 'Dag(ar)',
// 'Existing due date' => '', 'Existing due date' => 'Existerande förfallodatum',
// 'Factor to calculate new due date: ' => '', 'Factor to calculate new due date: ' => 'Faktor för att beräkna nytt förfallodatum: ',
// 'Month(s)' => '', 'Month(s)' => 'Månad(er)',
// 'Recurrence' => '', 'Recurrence' => 'Återkommande',
// 'This task has been created by: ' => '', 'This task has been created by: ' => 'Uppgiften har skapats av: ',
// 'Recurrent task has been generated:' => '', 'Recurrent task has been generated:' => 'Återkommande uppgift har genererats:',
// 'Timeframe to calculate new due date: ' => '', 'Timeframe to calculate new due date: ' => 'Tidsram för att beräkna nytt förfallodatum: ',
// 'Trigger to generate recurrent task: ' => '', 'Trigger to generate recurrent task: ' => 'Aktivera att generera återkommande uppgift: ',
// 'When task is closed' => '', 'When task is closed' => 'När uppgiften är stängd',
// 'When task is moved from first column' => '', 'When task is moved from first column' => 'När uppgiften flyttas från första kolumnen',
// 'When task is moved to last column' => '', 'When task is moved to last column' => 'När uppgiften flyttas till sista kolumnen',
// 'Year(s)' => '', 'Year(s)' => 'År',
// 'Jabber (XMPP)' => '', 'Jabber (XMPP)' => 'Jabber (XMPP)',
// 'Send notifications to Jabber' => '', 'Send notifications to Jabber' => 'Skicka notiser till Jabber',
// 'XMPP server address' => '', 'XMPP server address' => 'XMPP serveradress',
// 'Jabber domain' => '', 'Jabber domain' => 'Jabber domän',
// 'Jabber nickname' => '', 'Jabber nickname' => 'Jabber smeknamn',
// 'Multi-user chat room' => '', 'Multi-user chat room' => 'Multi-user chatrum',
// 'Help on Jabber integration' => '', 'Help on Jabber integration' => 'Hjälp för Jabber integration',
// 'The server address must use this format: "tcp://hostname:5222"' => '', 'The server address must use this format: "tcp://hostname:5222"' => 'Serveradressen måste använda detta format: "tcp://hostname:5222"',
// 'Calendar settings' => '', 'Calendar settings' => 'Inställningar för kalendern',
// 'Project calendar view' => '', 'Project calendar view' => 'Projektkalendervy',
// 'Project settings' => '', 'Project settings' => 'Projektinställningar',
// 'Show subtasks based on the time tracking' => '', 'Show subtasks based on the time tracking' => 'Visa deluppgifter baserade på tidsspårning',
// 'Show tasks based on the creation date' => '', 'Show tasks based on the creation date' => 'Visa uppgifter baserade på skapat datum',
// 'Show tasks based on the start date' => '', 'Show tasks based on the start date' => 'Visa uppgifter baserade på startdatum',
// 'Subtasks time tracking' => '', 'Subtasks time tracking' => 'Deluppgifter tidsspårning',
// 'User calendar view' => '', 'User calendar view' => 'Användarkalendervy',
// 'Automatically update the start date' => '', 'Automatically update the start date' => 'Automatisk uppdatering av startdatum',
// 'iCal feed' => '', 'iCal feed' => 'iCal flöde',
// 'Preferences' => '', 'Preferences' => 'Preferenser',
// 'Security' => '', 'Security' => 'Säkerhet',
// 'Two factor authentication disabled' => '', 'Two factor authentication disabled' => 'Tvåfaktorsverifiering inaktiverad',
// 'Two factor authentication enabled' => '', 'Two factor authentication enabled' => 'Tvåfaktorsverifiering aktiverad',
// 'Unable to update this user.' => '', 'Unable to update this user.' => 'Kunde inte uppdatera användaren.',
// 'There is no user management for private projects.' => '', 'There is no user management for private projects.' => 'Det finns ingen användarhantering för privata projekt.',
// 'User that will receive the email' => '', 'User that will receive the email' => 'Användare som kommer att ta emot mailet',
// 'Email subject' => '', 'Email subject' => 'E-post ämne',
// 'Date' => '', 'Date' => 'Datum',
// 'By @%s on Bitbucket' => '', 'By @%s on Bitbucket' => 'Av @%s på Bitbucket',
// 'Bitbucket Issue' => '', 'Bitbucket Issue' => 'Bitbucket fråga',
// 'Commit made by @%s on Bitbucket' => '', 'Commit made by @%s on Bitbucket' => 'Bidrag gjort av @%s på Bitbucket',
// 'Commit made by @%s on Github' => '', 'Commit made by @%s on Github' => 'Bidrag gjort av @%s på GitHub',
// 'By @%s on Github' => '', 'By @%s on Github' => 'Av @%s på GitHub',
// 'Commit made by @%s on Gitlab' => '', 'Commit made by @%s on Gitlab' => 'Bidrag gjort av @%s på Gitlab',
// 'Add a comment log when moving the task between columns' => '', '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' => '', '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' => '', 'Send a task by email to someone' => 'Skicka en uppgift med e-post till någon',
// 'Reopen a task' => '', 'Reopen a task' => 'Återöppna en uppgift',
// 'Bitbucket issue opened' => '', 'Bitbucket issue opened' => 'Bitbucketfråga öppnad',
// 'Bitbucket issue closed' => '', 'Bitbucket issue closed' => 'Bitbucketfråga stängd',
// 'Bitbucket issue reopened' => '', 'Bitbucket issue reopened' => 'Bitbucketfråga återöppnad',
// 'Bitbucket issue assignee change' => '', 'Bitbucket issue assignee change' => 'Bitbucketfråga tilldelningsändring',
// 'Bitbucket issue comment created' => '', 'Bitbucket issue comment created' => 'Bitbucketfråga kommentar skapad',
// 'Column change' => '', 'Column change' => 'Kolumnändring',
// 'Position change' => '', 'Position change' => 'Positionsändring',
// 'Swimlane change' => '', 'Swimlane change' => 'Swimlaneändring',
// 'Assignee change' => '', 'Assignee change' => 'Tilldelningsändring',
// '[%s] Overdue tasks' => '', '[%s] Overdue tasks' => '[%s] Försenade uppgifter',
// 'Notification' => '', 'Notification' => 'Notis',
// '%s moved the task #%d to the first swimlane' => '', '%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 moved the task #%d to the swimlane "%s"' => '%s flyttade uppgiften #%d till swimlane "%s"',
// 'Swimlane' => '', 'Swimlane' => 'Swimlane',
// 'Budget overview' => '', 'Budget overview' => 'Budgetöversikt',
// 'Type' => '', 'Type' => 'Typ',
// 'There is not enough data to show something.' => '', 'There is not enough data to show something.' => 'Det finns inte tillräckligt mycket data för att visa något.',
// 'Gravatar' => '', 'Gravatar' => 'Gravatar',
// 'Hipchat' => '', 'Hipchat' => 'Hipchat',
// 'Slack' => '', 'Slack' => 'Slack',
// '%s moved the task %s to the first swimlane' => '', '%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 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.' => '', '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.' => '', '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' => '', 'Project activities for %s' => 'Projektaktiviteter för %s',
// 'view the board on Kanboard' => '', 'view the board on Kanboard' => 'visa tavlan på Kanboard',
// 'The task have been moved to the first swimlane' => '', 'The task have been moved to the first swimlane' => 'Uppgiften har flyttats till första swimlane',
// 'The task have been moved to another swimlane:' => '', 'The task have been moved to another swimlane:' => 'Uppgiften har flyttats till en annan swimlane:',
// 'Overdue tasks for the project "%s"' => '', 'Overdue tasks for the project "%s"' => 'Försenade uppgifter för projektet "%s"',
// 'New title: %s' => '', 'New title: %s' => 'Ny titel: %s',
// 'The task is not assigned anymore' => '', 'The task is not assigned anymore' => 'Uppgiften är inte länge tilldelad',
// 'New assignee: %s' => '', 'New assignee: %s' => 'Ny tilldelning: %s',
// 'There is no category now' => '', 'There is no category now' => 'Det finns ingen kategori nu',
// 'New category: %s' => '', 'New category: %s' => 'Ny kategori: %s',
// 'New color: %s' => '', 'New color: %s' => 'Ny färg: %s',
// 'New complexity: %d' => '', 'New complexity: %d' => 'Ny komplexitet: %d',
// 'The due date have been removed' => '', 'The due date have been removed' => 'Förfallodatumet har tagits bort',
// 'There is no description anymore' => '', 'There is no description anymore' => 'Det finns ingen beskrivning längre',
// 'Recurrence settings have been modified' => '', 'Recurrence settings have been modified' => 'Återkommande inställning har ändrats',
// 'Time spent changed: %sh' => '', 'Time spent changed: %sh' => 'Spenderad tid har ändrats: %sh',
// 'Time estimated changed: %sh' => '', 'Time estimated changed: %sh' => 'Tidsuppskattning ändrad: %sh',
// 'The field "%s" have been updated' => '', 'The field "%s" have been updated' => 'Fältet "%s" har uppdaterats',
// 'The description have been modified' => '', 'The description have been modified' => 'Beskrivningen har modifierats',
// 'Do you really want to close the task "%s" as well as all subtasks?' => '', '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' => 'Swimlane: %s',
// 'I want to receive notifications for:' => '', 'I want to receive notifications for:' => 'Jag vill få notiser för:',
// 'All tasks' => '', 'All tasks' => 'Alla uppgifter',
// 'Only for tasks assigned to me' => '', 'Only for tasks assigned to me' => 'Bara för uppgifter tilldelade mig',
// 'Only for tasks created by me' => '', 'Only for tasks created by me' => 'Bara för uppgifter skapade av mig',
// 'Only for tasks created by me and assigned to me' => '', 'Only for tasks created by me and assigned to me' => 'Bara för uppgifter skapade av mig och tilldelade till mig',
// '%A' => '', '%A' => '%A',
// '%b %e, %Y, %k:%M %p' => '', '%b %e, %Y, %k:%M %p' => '%b %e, %Y, %k:%M %p',
// 'New due date: %B %e, %Y' => '', 'New due date: %B %e, %Y' => 'Nytt förfallodatum: %B %e, %Y',
// 'Start date changed: %B %e, %Y' => '', 'Start date changed: %B %e, %Y' => 'Startdatum ändrat: %B %e, %Y',
// '%k:%M %p' => '', '%k:%M %p' => '%k:%M %p',
// '%%Y-%%m-%%d' => '', '%%Y-%%m-%%d' => '%%Y-%%m-%%d',
// 'Total for all columns' => '', 'Total for all columns' => 'Totalt för alla kolumner',
// 'You need at least 2 days of data to show the chart.' => '', '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' => '<15m',
// '<30m' => '', '<30m' => '<30m',
// 'Stop timer' => '', 'Stop timer' => 'Stoppa timer',
// 'Start timer' => '', 'Start timer' => 'Starta timer',
// 'Add project member' => '', 'Add project member' => 'Lägg till projektmedlem',
// 'Enable notifications' => '', 'Enable notifications' => 'Aktivera notiser',
// 'My activity stream' => '', 'My activity stream' => 'Min aktivitetsström',
// 'My calendar' => '', 'My calendar' => 'Min kalender',
// 'Search tasks' => '', 'Search tasks' => 'Sök uppgifter',
// 'Back to the calendar' => '', 'Back to the calendar' => 'Tillbaka till kalendern',
// 'Filters' => '', 'Filters' => 'Filter',
// 'Reset filters' => '', 'Reset filters' => 'Återställ filter',
// 'My tasks due tomorrow' => '', 'My tasks due tomorrow' => 'Mina uppgifter förfaller imorgon',
// 'Tasks due today' => '', 'Tasks due today' => 'Uppgifter förfaller idag',
// 'Tasks due tomorrow' => '', 'Tasks due tomorrow' => 'Uppgifter förfaller imorgon',
// 'Tasks due yesterday' => '', 'Tasks due yesterday' => 'Uppgifter förföll igår',
// 'Closed tasks' => '', 'Closed tasks' => 'Stängda uppgifter',
// 'Open tasks' => '', 'Open tasks' => 'Öppna uppgifter',
// 'Not assigned' => '', 'Not assigned' => 'Inte tilldelad',
// 'View advanced search syntax' => '', 'View advanced search syntax' => 'Visa avancerad söksyntax',
// 'Overview' => '', 'Overview' => 'Översikts',
// '%b %e %Y' => '', '%b %e %Y' => '%b %e %Y',
// 'Board/Calendar/List view' => '', 'Board/Calendar/List view' => 'Tavla/Kalender/Listvy',
// 'Switch to the board view' => '', 'Switch to the board view' => 'Växla till tavelvy',
// 'Switch to the calendar view' => '', 'Switch to the calendar view' => 'Växla till kalendervy',
// 'Switch to the list view' => '', 'Switch to the list view' => 'Växla till listvy',
// 'Go to the search/filter box' => '', 'Go to the search/filter box' => 'Gå till sök/filter box',
// 'There is no activity yet.' => '', 'There is no activity yet.' => 'Det finns ingen aktivitet ännu.',
// 'No tasks found.' => '', 'No tasks found.' => 'Inga uppgifter hittades.',
// 'Keyboard shortcut: "%s"' => '', 'Keyboard shortcut: "%s"' => 'Tangentbordsgenväg: "%s"',
// 'List' => '', 'List' => 'Lista',
// 'Filter' => '', 'Filter' => 'Filter',
// 'Advanced search' => '', 'Advanced search' => 'Avancerad sök',
// 'Example of query: ' => '', 'Example of query: ' => 'Exempel på fråga',
// 'Search by project: ' => '', 'Search by project: ' => 'Sök efter projekt:',
// 'Search by column: ' => '', 'Search by column: ' => 'Sök efter kolumn:',
// 'Search by assignee: ' => '', 'Search by assignee: ' => 'Sök efter tilldelad:',
// 'Search by color: ' => '', 'Search by color: ' => 'Sök efter färg:',
// 'Search by category: ' => '', 'Search by category: ' => 'Sök efter kategori:',
// 'Search by description: ' => '', 'Search by description: ' => 'Sök efter beskrivning',
// 'Search by due date: ' => '', 'Search by due date: ' => 'Sök efter förfallodatum',
); );

View File

@@ -49,11 +49,6 @@ class Authentication extends Base
return false; return false;
} }
// We update each time the RememberMe cookie tokens
if ($this->backend('rememberMe')->hasCookie()) {
$this->backend('rememberMe')->refresh();
}
return true; return true;
} }

View File

@@ -85,8 +85,7 @@ class DateParser extends Base
*/ */
public function getTimestamp($value) public function getTimestamp($value)
{ {
foreach ($this->getDateFormats() as $format) { foreach ($this->getAllFormats() as $format) {
$timestamp = $this->getValidDate($value, $format); $timestamp = $this->getValidDate($value, $format);
if ($timestamp !== 0) { if ($timestamp !== 0) {
@@ -97,6 +96,25 @@ class DateParser extends Base
return 0; return 0;
} }
/**
* 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) * Return the list of supported date formats (for the parser)
* *
@@ -112,6 +130,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) * Return the list of available date formats (for the config page)
* *
@@ -143,7 +176,7 @@ class DateParser extends Base
* Get a timetstamp from an ISO date format * Get a timetstamp from an ISO date format
* *
* @access public * @access public
* @param string $date Date format * @param string $date
* @return integer * @return integer
*/ */
public function getTimestampFromIsoFormat($date) public function getTimestampFromIsoFormat($date)
@@ -166,7 +199,6 @@ class DateParser extends Base
} }
foreach ($fields as $field) { foreach ($fields as $field) {
if (! empty($values[$field])) { if (! empty($values[$field])) {
$values[$field] = date($format, $values[$field]); $values[$field] = date($format, $values[$field]);
} }
@@ -180,15 +212,16 @@ class DateParser extends Base
* Convert date (form input data) * Convert date (form input data)
* *
* @access public * @access public
* @param array $values Database values * @param array $values Database values
* @param string[] $fields Date fields * @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) { foreach ($fields as $field) {
if (! empty($values[$field]) && ! is_numeric($values[$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);
} }
} }
} }

View File

@@ -49,7 +49,7 @@ class ProjectAnalytic extends Base
* Get users repartition * Get users repartition
* *
* @access public * @access public
* @param integer $project_id Project id * @param integer $project_id
* @return array * @return array
*/ */
public function getUserRepartition($project_id) public function getUserRepartition($project_id)
@@ -87,4 +87,96 @@ class ProjectAnalytic extends Base
return array_values($metrics); 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;
}
} }

View File

@@ -3,22 +3,22 @@
namespace Model; namespace Model;
/** /**
* Project daily summary * Project Daily Column Stats
* *
* @package model * @package model
* @author Frederic Guillot * @author Frederic Guillot
*/ */
class ProjectDailySummary extends Base class ProjectDailyColumnStats extends Base
{ {
/** /**
* SQL table name * SQL table name
* *
* @var string * @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 * "total" is the number open of tasks in the column
* "score" is the sum of tasks score 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 // This call will fail if the record already exists
// (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE) // (cross database driver hack for INSERT..ON DUPLICATE KEY UPDATE)
$db->table(ProjectDailySummary::TABLE)->insert(array( $db->table(ProjectDailyColumnStats::TABLE)->insert(array(
'day' => $date, 'day' => $date,
'project_id' => $project_id, 'project_id' => $project_id,
'column_id' => $column_id, 'column_id' => $column_id,
@@ -46,7 +46,7 @@ class ProjectDailySummary extends Base
'score' => 0, 'score' => 0,
)); ));
$db->table(ProjectDailySummary::TABLE) $db->table(ProjectDailyColumnStats::TABLE)
->eq('project_id', $project_id) ->eq('project_id', $project_id)
->eq('column_id', $column_id) ->eq('column_id', $column_id)
->eq('day', $date) ->eq('day', $date)
@@ -95,19 +95,19 @@ class ProjectDailySummary extends Base
*/ */
public function getRawMetrics($project_id, $from, $to) public function getRawMetrics($project_id, $from, $to)
{ {
return $this->db->table(ProjectDailySummary::TABLE) return $this->db->table(ProjectDailyColumnStats::TABLE)
->columns( ->columns(
ProjectDailySummary::TABLE.'.column_id', ProjectDailyColumnStats::TABLE.'.column_id',
ProjectDailySummary::TABLE.'.day', ProjectDailyColumnStats::TABLE.'.day',
ProjectDailySummary::TABLE.'.total', ProjectDailyColumnStats::TABLE.'.total',
ProjectDailySummary::TABLE.'.score', ProjectDailyColumnStats::TABLE.'.score',
Board::TABLE.'.title AS column_title' Board::TABLE.'.title AS column_title'
) )
->join(Board::TABLE, 'id', 'column_id') ->join(Board::TABLE, 'id', 'column_id')
->eq(ProjectDailySummary::TABLE.'.project_id', $project_id) ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id)
->gte('day', $from) ->gte('day', $from)
->lte('day', $to) ->lte('day', $to)
->asc(ProjectDailySummary::TABLE.'.day') ->asc(ProjectDailyColumnStats::TABLE.'.day')
->findAll(); ->findAll();
} }
@@ -122,17 +122,17 @@ class ProjectDailySummary extends Base
*/ */
public function getRawMetricsByDay($project_id, $from, $to) public function getRawMetricsByDay($project_id, $from, $to)
{ {
return $this->db->table(ProjectDailySummary::TABLE) return $this->db->table(ProjectDailyColumnStats::TABLE)
->columns( ->columns(
ProjectDailySummary::TABLE.'.day', ProjectDailyColumnStats::TABLE.'.day',
'SUM('.ProjectDailySummary::TABLE.'.total) AS total', 'SUM('.ProjectDailyColumnStats::TABLE.'.total) AS total',
'SUM('.ProjectDailySummary::TABLE.'.score) AS score' 'SUM('.ProjectDailyColumnStats::TABLE.'.score) AS score'
) )
->eq(ProjectDailySummary::TABLE.'.project_id', $project_id) ->eq(ProjectDailyColumnStats::TABLE.'.project_id', $project_id)
->gte('day', $from) ->gte('day', $from)
->lte('day', $to) ->lte('day', $to)
->asc(ProjectDailySummary::TABLE.'.day') ->asc(ProjectDailyColumnStats::TABLE.'.day')
->groupBy(ProjectDailySummary::TABLE.'.day') ->groupBy(ProjectDailyColumnStats::TABLE.'.day')
->findAll(); ->findAll();
} }
@@ -160,7 +160,7 @@ class ProjectDailySummary extends Base
$aggregates = array(); $aggregates = array();
// Fetch metrics for the project // Fetch metrics for the project
$records = $this->db->table(ProjectDailySummary::TABLE) $records = $this->db->table(ProjectDailyColumnStats::TABLE)
->eq('project_id', $project_id) ->eq('project_id', $project_id)
->gte('day', $from) ->gte('day', $from)
->lte('day', $to) ->lte('day', $to)

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -43,9 +43,10 @@ class TaskCreation extends Base
*/ */
public function prepare(array &$values) 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->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'])) { if (empty($values['column_id'])) {
$values['column_id'] = $this->board->getFirstColumn($values['project_id']); $values['column_id'] = $this->board->getFirstColumn($values['project_id']);
@@ -59,6 +60,10 @@ class TaskCreation extends Base
$values['title'] = t('Untitled'); $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['swimlane_id'] = empty($values['swimlane_id']) ? 0 : $values['swimlane_id'];
$values['date_creation'] = time(); $values['date_creation'] = time();
$values['date_modification'] = $values['date_creation']; $values['date_modification'] = $values['date_creation'];

View File

@@ -118,7 +118,7 @@ class TaskFilter extends Base
* Exclude a list of task_id * Exclude a list of task_id
* *
* @access public * @access public
* @param array $task_ids * @param integer[] $task_ids
* @return TaskFilter * @return TaskFilter
*/ */
public function excludeTasks(array $task_ids) public function excludeTasks(array $task_ids)
@@ -641,10 +641,10 @@ class TaskFilter extends Base
* Transform results to ical events * Transform results to ical events
* *
* @access public * @access public
* @param string $start_column Column name for the start date * @param string $start_column Column name for the start date
* @param string $end_column Column name for the end date * @param string $end_column Column name for the end date
* @param Eluceo\iCal\Component\Calendar $vCalendar Calendar object * @param Calendar $vCalendar Calendar object
* @return Eluceo\iCal\Component\Calendar * @return Calendar
*/ */
public function addDateTimeIcalEvents($start_column, $end_column, Calendar $vCalendar = null) 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 * Transform results to all day ical events
* *
* @access public * @access public
* @param string $column Column name for the date * @param string $column Column name for the date
* @param Eluceo\iCal\Component\Calendar $vCalendar Calendar object * @param Calendar $vCalendar Calendar object
* @return Eluceo\iCal\Component\Calendar * @return Calendar
*/ */
public function addAllDayIcalEvents($column = 'date_due', Calendar $vCalendar = null) public function addAllDayIcalEvents($column = 'date_due', Calendar $vCalendar = null)
{ {
@@ -706,7 +706,7 @@ class TaskFilter extends Base
* @access protected * @access protected
* @param array $task * @param array $task
* @param string $uid * @param string $uid
* @return Eluceo\iCal\Component\Event * @return Event
*/ */
protected function getTaskIcalEvent(array &$task, $uid) protected function getTaskIcalEvent(array &$task, $uid)
{ {
@@ -723,11 +723,11 @@ class TaskFilter extends Base
$vEvent->setSummary(t('#%d', $task['id']).' '.$task['title']); $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']))); $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'])) { if (! empty($task['owner_id'])) {
$vEvent->setOrganizer('MAILTO:'.($task['creator_email'] ?: $task['creator_username'].'@kanboard.local')); $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 = new Attendees;
$attendees->add('MAILTO:'.($task['creator_email'] ?: $task['creator_username'].'@kanboard.local')); $attendees->add('MAILTO:'.($task['creator_email'] ?: $task['creator_username'].'@kanboard.local'));
$vEvent->setAttendees($attendees); $vEvent->setAttendees($attendees);

View File

@@ -4,7 +4,6 @@ namespace Model;
use SimpleValidator\Validator; use SimpleValidator\Validator;
use SimpleValidator\Validators; use SimpleValidator\Validators;
use PicoDb\Table;
/** /**
* TaskLink model * TaskLink model

View File

@@ -83,7 +83,8 @@ class TaskModification extends Base
*/ */
public function prepare(array &$values) 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->removeFields($values, array('another_task', 'id'));
$this->resetFields($values, array('date_due', 'date_started', 'score', 'category_id', 'time_estimated', 'time_spent')); $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')); $this->convertIntegerFields($values, array('is_active', 'recurrence_status', 'recurrence_trigger', 'recurrence_factor', 'recurrence_timeframe', 'recurrence_basedate'));

View File

@@ -26,109 +26,176 @@ class TaskPosition extends Base
*/ */
public function movePosition($project_id, $task_id, $column_id, $position, $swimlane_id = 0, $fire_events = true) 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 // Ignore closed tasks
if ($original_task['is_active'] == Task::STATUS_CLOSED) { if ($task['is_active'] == Task::STATUS_CLOSED) {
return true; return true;
} }
$result = $this->calculateAndSave($project_id, $task_id, $column_id, $position, $swimlane_id); $result = false;
if ($result) { 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 ($original_task['swimlane_id'] != $swimlane_id) { if ($result && $fire_events) {
$this->calculateAndSave($project_id, 0, $column_id, 1, $original_task['swimlane_id']); $this->fireEvents($task, $column_id, $position, $swimlane_id);
}
if ($fire_events) {
$this->fireEvents($original_task, $column_id, $position, $swimlane_id);
}
} }
return $result; return $result;
} }
/** /**
* Calculate the new position of all tasks * Move a task to another swimlane
* *
* @access public * @access private
* @param integer $project_id Project id * @param integer $project_id
* @param integer $task_id Task id * @param integer $task_id
* @param integer $column_id Column id * @param integer $position
* @param integer $position Position (must be >= 1) * @param integer $original_column_id
* @param integer $swimlane_id Swimlane id * @param integer $new_column_id
* @return array|boolean * @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 $this->db->startTransaction();
if ($position < 1) { $r1 = $this->saveTaskPositions($project_id, $task_id, 0, $original_column_id, $original_swimlane_id);
return false; $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'); return $r1 && $r2;
$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');
}
// The column must exists
if (! isset($columns[$column_id])) {
return false;
}
// We put our task to the new position
if ($task_id) {
array_splice($columns[$column_id], $position - 1, 0, $task_id);
}
return $columns;
} }
/** /**
* Save task positions * Move a task to another column
* *
* @access private * @access private
* @param array $columns Sorted tasks * @param integer $project_id
* @param integer $swimlane_id Swimlane id * @param integer $task_id
* @param integer $position
* @param integer $swimlane_id
* @param integer $original_column_id
* @param integer $new_column_id
* @return boolean * @return boolean
*/ */
private function savePositions(array $columns, $swimlane_id) private function saveColumnChange($project_id, $task_id, $position, $swimlane_id, $original_column_id, $new_column_id)
{ {
return $this->db->transaction(function ($db) use ($columns, $swimlane_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();
foreach ($columns as $column_id => $column) { return $r1 && $r2;
}
$position = 1; /**
* 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();
foreach ($column as $task_id) { return $result;
}
$result = $db->table(Task::TABLE)->eq('id', $task_id)->update(array( /**
'position' => $position, * Save all task positions for one column
'column_id' => $column_id, *
'swimlane_id' => $swimlane_id, * @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 saveTaskPositions($project_id, $task_id, $position, $column_id, $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');
if (! $result) { $offset = 1;
return false;
}
$position++; 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++;
} }
});
// Rewrite other tasks position
if (! $this->saveTaskPosition($current_task_id, $offset, $column_id, $swimlane_id)) {
return false;
}
$offset++;
}
// 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;
}
return true;
}
/**
* 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;
}
return true;
} }
/** /**
@@ -165,26 +232,4 @@ class TaskPosition extends Base
$this->container['dispatcher']->dispatch(Task::EVENT_MOVE_POSITION, new TaskEvent($event_data)); $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;
}
} }

View File

@@ -39,7 +39,7 @@ class TaskValidator extends Base
new Validators\Integer('recurrence_status', t('This value must be an integer')), 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\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_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_spent', t('This value must be numeric')),
new Validators\Numeric('time_estimated', t('This value must be numeric')), new Validators\Numeric('time_estimated', t('This value must be numeric')),
); );

View File

@@ -38,6 +38,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 * Get all transitions by task
* *

View File

@@ -118,4 +118,28 @@ class UserSession extends Base
{ {
$_SESSION['filters'][$project_id] = $filters; $_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;
}
} }

View File

@@ -6,7 +6,32 @@ use PDO;
use Core\Security; use Core\Security;
use Model\Link; 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) function version_77($pdo)
{ {

View File

@@ -6,7 +6,31 @@ use PDO;
use Core\Security; use Core\Security;
use Model\Link; 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) function version_57($pdo)
{ {

View File

@@ -6,7 +6,31 @@ use Core\Security;
use PDO; use PDO;
use Model\Link; 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) function version_73($pdo)
{ {

View File

@@ -33,7 +33,8 @@ class ClassProvider implements ServiceProviderInterface
'ProjectActivity', 'ProjectActivity',
'ProjectAnalytic', 'ProjectAnalytic',
'ProjectDuplication', 'ProjectDuplication',
'ProjectDailySummary', 'ProjectDailyColumnStats',
'ProjectDailyStats',
'ProjectIntegration', 'ProjectIntegration',
'ProjectPermission', 'ProjectPermission',
'Subtask', 'Subtask',
@@ -42,6 +43,7 @@ class ClassProvider implements ServiceProviderInterface
'SubtaskTimeTracking', 'SubtaskTimeTracking',
'Swimlane', 'Swimlane',
'Task', 'Task',
'TaskAnalytic',
'TaskCreation', 'TaskCreation',
'TaskDuplication', 'TaskDuplication',
'TaskExport', 'TaskExport',

View File

@@ -22,7 +22,8 @@ class ProjectDailySummarySubscriber extends \Core\Base implements EventSubscribe
public function execute(TaskEvent $event) public function execute(TaskEvent $event)
{ {
if (isset($event['project_id'])) { 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'));
} }
} }
} }

View File

@@ -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 ?>

View File

@@ -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 ?>

View File

@@ -13,5 +13,11 @@
<li> <li>
<?= $this->url->link(t('Burndown chart'), 'analytic', 'burndown', array('project_id' => $project['id'])) ?> <?= $this->url->link(t('Burndown chart'), 'analytic', 'burndown', array('project_id' => $project['id'])) ?>
</li> </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> </ul>
</div> </div>

View File

@@ -10,53 +10,55 @@
<?= $this->render('board/task_menu', array('task' => $task)) ?> <?= $this->render('board/task_menu', array('task' => $task)) ?>
<div class="task-board-collapsed" style="display: none"> <?php if ($this->board->isCollapsed($project['id'])): ?>
<?php if (! empty($task['assignee_username'])): ?> <div class="task-board-collapsed">
<span title="<?= $this->e($task['assignee_name'] ?: $task['assignee_username']) ?>"> <?php if (! empty($task['assignee_username'])): ?>
<?= $this->e($this->user->getInitials($task['assignee_name'] ?: $task['assignee_username'])) ?> <span title="<?= $this->e($task['assignee_name'] ?: $task['assignee_username']) ?>">
</span> - <?= $this->e($this->user->getInitials($task['assignee_name'] ?: $task['assignee_username'])) ?>
<?php endif ?> </span> -
<span class="tooltip" title="<?= $this->e($task['title']) ?>" <?php endif ?>
<?= $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 class="tooltip" title="<?= $this->e($task['title']) ?>"
</span> <?= $this->url->link($this->e($task['title']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'task-board-collapsed-title') ?>
</div> </span>
<div class="task-board-expanded">
<?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>
<?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> </div>
<?php else: ?> <?php else: ?>
<div class="task-board-closed"><i class="fa fa-ban fa-fw"></i><?= t('Closed') ?></div> <div class="task-board-expanded">
<?php endif ?>
<div class="task-board-title"> <?php if ($task['reference']): ?>
<?= $this->url->link($this->e($task['title']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, '', t('View this task')) ?> <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>
<?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 ?>
<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>
<?= $this->render('board/task_footer', array(
'task' => $task,
'not_editable' => $not_editable,
)) ?>
</div> </div>
<?php endif ?>
<?= $this->render('board/task_footer', array(
'task' => $task,
'not_editable' => $not_editable,
)) ?>
</div>
</div> </div>

View File

@@ -77,6 +77,8 @@
<?= $this->form->label(t('Webhook URL'), 'integration_slack_webhook_url') ?> <?= $this->form->label(t('Webhook URL'), 'integration_slack_webhook_url') ?>
<?= $this->form->text('integration_slack_webhook_url', $values, $errors) ?> <?= $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> <p class="form-help"><a href="http://kanboard.net/documentation/slack" target="_blank"><?= t('Help on Slack integration') ?></a></p>
</div> </div>

View File

@@ -48,7 +48,7 @@
<ul> <ul>
<?php if (isset($board_selector) && ! empty($board_selector)): ?> <?php if (isset($board_selector) && ! empty($board_selector)): ?>
<li> <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> <option value=""></option>
<?php foreach($board_selector as $board_id => $board_name): ?> <?php foreach($board_selector as $board_id => $board_name): ?>
<option value="<?= $board_id ?>"><?= $this->e($board_name) ?></option> <option value="<?= $board_id ?>"><?= $this->e($board_name) ?></option>

View File

@@ -5,12 +5,13 @@
<ul> <ul>
<?php if (isset($is_board)): ?> <?php if (isset($is_board)): ?>
<li> <li>
<span class="filter-collapse"> <?php if ($this->board->isCollapsed($project['id'])): ?>
<i class="fa fa-compress fa-fw"></i> <a href="#" class="filter-collapse-link" title="<?= t('Keyboard shortcut: "%s"', 's') ?>"><?= t('Collapse tasks') ?></a> <i class="fa fa-expand fa-fw"></i>
</span> <?= $this->url->link(t('Expand tasks'), 'board', 'expand', array('project_id' => $project['id']), false, 'board-display-mode', t('Keyboard shortcut: "%s"', 's')) ?>
<span class="filter-expand" style="display: none"> <?php else: ?>
<i class="fa fa-expand fa-fw"></i> <a href="#" class="filter-expand-link" title="<?= t('Keyboard shortcut: "%s"', 's') ?>"><?= t('Expand tasks') ?></a> <i class="fa fa-compress fa-fw"></i>
</span> <?= $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>
<li> <li>
<span class="filter-compact"> <span class="filter-compact">

View File

@@ -85,6 +85,8 @@
<?= $this->form->label(t('Webhook URL'), 'slack_webhook_url') ?> <?= $this->form->label(t('Webhook URL'), 'slack_webhook_url') ?>
<?= $this->form->text('slack_webhook_url', $values, $errors) ?> <?= $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> <p class="form-help"><a href="http://kanboard.net/documentation/slack" target="_blank"><?= t('Help on Slack integration') ?></a></p>

View File

@@ -48,7 +48,7 @@
<?php if ($subtask['is_timer_started']): ?> <?php if ($subtask['is_timer_started']): ?>
<i class="fa fa-pause"></i> <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->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: ?> <?php else: ?>
<i class="fa fa-play-circle-o"></i> <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'])) ?> <?= $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'])) ?>

View File

@@ -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') ?>

View File

@@ -3,7 +3,7 @@
<ul> <ul>
<li> <li>
<i class="fa fa-th fa-fw"></i> <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>
<li> <li>
<i class="fa fa-calendar fa-fw"></i> <i class="fa fa-calendar fa-fw"></i>

View File

@@ -5,11 +5,14 @@
<?= $this->url->link(t('Summary'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?> <?= $this->url->link(t('Summary'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li> </li>
<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>
<li> <li>
<?= $this->url->link(t('Transitions'), 'task', 'transitions', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?> <?= $this->url->link(t('Transitions'), 'task', 'transitions', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li> </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): ?> <?php if ($task['time_estimated'] > 0 || $task['time_spent'] > 0): ?>
<li> <li>
<?= $this->url->link(t('Time tracking'), 'task', 'timesheet', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?> <?= $this->url->link(t('Time tracking'), 'task', 'timesheet', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>

View File

@@ -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"> <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->csrf() ?>
<?= $this->form->hidden('id', $values) ?> <?= $this->form->hidden('id', $values) ?>
<?= $this->form->label(t('Start date'), 'date_started') ?> <?= $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->label(t('Time estimated'), 'time_estimated') ?>
<?= $this->form->numeric('time_estimated', $values, array(), array('placeholder="'.t('hours').'"')) ?> <?= $this->form->numeric('time_estimated', $values, array(), array('placeholder="'.t('hours').'"')) ?>

View File

@@ -19,7 +19,7 @@
<td><?= $this->e($transition['src_column']) ?></td> <td><?= $this->e($transition['src_column']) ?></td>
<td><?= $this->e($transition['dst_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><?= $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> </tr>
<?php endforeach ?> <?php endforeach ?>
</table> </table>

View File

@@ -30,10 +30,10 @@
<?= $this->form->csrf() ?> <?= $this->form->csrf() ?>
<?= $this->form->label(t('Start time'), 'start') ?> <?= $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->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"> <div class="form-actions">
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/> <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>

View File

@@ -42,10 +42,10 @@
<?= $this->form->checkbox('all_day', t('All day'), 1) ?> <?= $this->form->checkbox('all_day', t('All day'), 1) ?>
<?= $this->form->label(t('Start time'), 'start') ?> <?= $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->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->label(t('Comment'), 'comment') ?>
<?= $this->form->text('comment', $values, $errors) ?> <?= $this->form->text('comment', $values, $errors) ?>

View File

@@ -42,10 +42,10 @@
<?= $this->form->checkbox('all_day', t('All day'), 1) ?> <?= $this->form->checkbox('all_day', t('All day'), 1) ?>
<?= $this->form->label(t('Start time'), 'start') ?> <?= $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->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->label(t('Comment'), 'comment') ?>
<?= $this->form->text('comment', $values, $errors) ?> <?= $this->form->text('comment', $values, $errors) ?>

View File

@@ -13,7 +13,7 @@
</tr> </tr>
<?php foreach ($timetable as $slot): ?> <?php foreach ($timetable as $slot): ?>
<tr> <tr>
<td><?= $this->datetime->getWeekDay($slot['day']) ?></td> <td><?= $this->dt->getWeekDay($slot['day']) ?></td>
<td><?= $slot['start'] ?></td> <td><?= $slot['start'] ?></td>
<td><?= $slot['end'] ?></td> <td><?= $slot['end'] ?></td>
<td> <td>
@@ -32,13 +32,13 @@
<?= $this->form->csrf() ?> <?= $this->form->csrf() ?>
<?= $this->form->label(t('Day'), 'day') ?> <?= $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->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->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"> <div class="form-actions">
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/> <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>

View File

@@ -1,6 +1,6 @@
<?php <?php
require 'vendor/autoload.php'; require dirname(__DIR__) . '/vendor/autoload.php';
// Automatically parse environment configuration (Heroku) // Automatically parse environment configuration (Heroku)
if (getenv('DATABASE_URL')) { if (getenv('DATABASE_URL')) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -52,3 +52,8 @@ hr {
float: left; float: left;
margin-right: 10px; margin-right: 10px;
} }
/* datepicker */
#ui-datepicker-div {
font-size: 0.8em;
}

View File

@@ -137,6 +137,7 @@ ul.form-errors li {
display: inline; display: inline;
} }
input.form-datetime,
input.form-date { input.form-date {
width: 150px; width: 150px;
} }

View File

@@ -1,8 +1,3 @@
/* datepicker */
#ui-datepicker-div {
font-size: 0.8em;
}
/* pagination */ /* pagination */
.pagination { .pagination {
text-align: center; text-align: center;

View File

@@ -1,10 +1,10 @@
/* task inside the board */ /* task inside the board */
.task-board { .task-board {
position: relative; position: relative;
margin-bottom: 5px; margin-bottom: 2px;
border: 1px solid #000; border: 1px solid #000;
padding: 3px; padding: 2px;
font-size: 0.9em; font-size: 0.85em;
word-wrap: break-word; word-wrap: break-word;
} }
@@ -44,11 +44,12 @@ a.task-board-collapsed-title {
.task-board .dropdown { .task-board .dropdown {
float: left; float: left;
margin-right: 5px; margin-right: 5px;
font-size: 1.1em;
} }
.task-board-title { .task-board-title {
margin-top: 10px; margin-top: 5px;
margin-bottom: 10px; margin-bottom: 5px;
font-size: 1.1em; font-size: 1.1em;
} }
@@ -77,8 +78,6 @@ a.task-board-nobody {
.task-board-category-container { .task-board-category-container {
text-align: right; text-align: right;
padding-bottom: 2px;
margin-top: 10px;
} }
.task-board-category { .task-board-category {
@@ -289,3 +288,12 @@ span.task-board-date-overdue {
.task-show-file-table { .task-show-file-table {
width: auto; width: auto;
} }
.task-show-start-link {
color: #000;
}
.task-show-start-link:hover,
.task-show-start-link:focus {
color: red;
}

View File

@@ -0,0 +1,5 @@
/*! jQuery Timepicker Addon - v1.5.5 - 2015-05-24
* http://trentrichardson.com/examples/timepicker
* Copyright (c) 2015 Trent Richardson; Licensed MIT */
.ui-timepicker-div .ui-widget-header{margin-bottom:8px}.ui-timepicker-div dl{text-align:left}.ui-timepicker-div dl dt{float:left;clear:left;padding:0 0 0 5px}.ui-timepicker-div dl dd{margin:0 10px 10px 40%}.ui-timepicker-div td{font-size:90%}.ui-tpicker-grid-label{background:0 0;border:0;margin:0;padding:0}.ui-timepicker-div .ui_tpicker_unit_hide{display:none}.ui-timepicker-rtl{direction:rtl}.ui-timepicker-rtl dl{text-align:right;padding:0 5px 0 0}.ui-timepicker-rtl dl dt{float:right;clear:right}.ui-timepicker-rtl dl dd{margin:0 40% 10px 10px}.ui-timepicker-div.ui-timepicker-oneLine{padding-right:2px}.ui-timepicker-div.ui-timepicker-oneLine .ui_tpicker_time,.ui-timepicker-div.ui-timepicker-oneLine dt{display:none}.ui-timepicker-div.ui-timepicker-oneLine .ui_tpicker_time_label{display:block;padding-top:2px}.ui-timepicker-div.ui-timepicker-oneLine dl{text-align:right}.ui-timepicker-div.ui-timepicker-oneLine dl dd,.ui-timepicker-div.ui-timepicker-oneLine dl dd>div{display:inline-block;margin:0}.ui-timepicker-div.ui-timepicker-oneLine dl dd.ui_tpicker_minute:before,.ui-timepicker-div.ui-timepicker-oneLine dl dd.ui_tpicker_second:before{content:':';display:inline-block}.ui-timepicker-div.ui-timepicker-oneLine dl dd.ui_tpicker_millisec:before,.ui-timepicker-div.ui-timepicker-oneLine dl dd.ui_tpicker_microsec:before{content:'.';display:inline-block}.ui-timepicker-div.ui-timepicker-oneLine .ui_tpicker_unit_hide,.ui-timepicker-div.ui-timepicker-oneLine .ui_tpicker_unit_hide:before{display:none}

File diff suppressed because one or more lines are too long

View File

@@ -6,6 +6,9 @@
var metrics = $("#chart").data("metrics"); var metrics = $("#chart").data("metrics");
var columns = []; var columns = [];
var groups = []; var groups = [];
var categories = [];
var inputFormat = d3.time.format("%Y-%m-%d");
var outputFormat = d3.time.format($("#chart").data("date-format"));
for (var i = 0; i < metrics.length; i++) { for (var i = 0; i < metrics.length; i++) {
@@ -19,7 +22,12 @@
} }
} }
else { else {
columns[j].push(metrics[i][j]); columns[j].push(metrics[i][j]);
if (j == 0) {
categories.push(outputFormat(inputFormat.parse(metrics[i][j])));
}
} }
} }
} }
@@ -27,16 +35,13 @@
c3.generate({ c3.generate({
data: { data: {
columns: columns, columns: columns,
x: metrics[0][0],
type: 'area-spline', type: 'area-spline',
groups: [groups] groups: [groups]
}, },
axis: { axis: {
x: { x: {
type: 'timeseries', type: 'category',
tick: { categories: categories
format: $("#chart").data("date-format")
}
} }
} }
}); });
@@ -47,6 +52,9 @@
{ {
var metrics = $("#chart").data("metrics"); var metrics = $("#chart").data("metrics");
var columns = [[$("#chart").data("label-total")]]; var columns = [[$("#chart").data("label-total")]];
var categories = [];
var inputFormat = d3.time.format("%Y-%m-%d");
var outputFormat = d3.time.format($("#chart").data("date-format"));
for (var i = 0; i < metrics.length; i++) { for (var i = 0; i < metrics.length; i++) {
@@ -66,21 +74,22 @@
columns[0][i] += metrics[i][j]; columns[0][i] += metrics[i][j];
} }
if (j == 0) {
categories.push(outputFormat(inputFormat.parse(metrics[i][j])));
}
} }
} }
} }
c3.generate({ c3.generate({
data: { data: {
columns: columns, columns: columns
x: metrics[0][0]
}, },
axis: { axis: {
x: { x: {
type: 'timeseries', type: 'category',
tick: { categories: categories
format: $("#chart").data("date-format")
}
} }
} }
}); });
@@ -125,11 +134,13 @@
// Draw budget chart // Draw budget chart
function drawBudget() function drawBudget()
{ {
var categories = [];
var metrics = $("#chart").data("metrics"); var metrics = $("#chart").data("metrics");
var labels = $("#chart").data("labels"); var labels = $("#chart").data("labels");
var inputFormat = d3.time.format("%Y-%m-%d");
var outputFormat = d3.time.format($("#chart").data("date-format"));
var columns = [ var columns = [
[labels["date"]],
[labels["in"]], [labels["in"]],
[labels["left"]], [labels["left"]],
[labels["out"]] [labels["out"]]
@@ -141,30 +152,178 @@
colors[labels["out"]] = '#DF3A01'; colors[labels["out"]] = '#DF3A01';
for (var i = 0; i < metrics.length; i++) { for (var i = 0; i < metrics.length; i++) {
columns[0].push(metrics[i]["date"]); categories.push(outputFormat(inputFormat.parse(metrics[i]["date"])));
columns[1].push(metrics[i]["in"]); columns[0].push(metrics[i]["in"]);
columns[2].push(metrics[i]["left"]); columns[1].push(metrics[i]["left"]);
columns[3].push(metrics[i]["out"]); columns[2].push(metrics[i]["out"]);
} }
c3.generate({ c3.generate({
data: { data: {
x: columns[0][0],
columns: columns, columns: columns,
colors: colors, colors: colors,
type : 'bar' type : 'bar'
}, },
bar: {
width: {
ratio: 0.25
}
},
grid: {
x: {
show: true
},
y: {
show: true
}
},
axis: { axis: {
x: { x: {
type: 'timeseries', type: 'category',
categories: categories
}
}
});
}
// Draw chart for average time spent into each column
function drawAvgTimeColumn()
{
var metrics = $("#chart").data("metrics");
var plots = [$("#chart").data("label")];
var categories = [];
for (var column_id in metrics) {
plots.push(metrics[column_id].average);
categories.push(metrics[column_id].title);
}
c3.generate({
data: {
columns: [plots],
type: 'bar'
},
bar: {
width: {
ratio: 0.5
}
},
axis: {
x: {
type: 'category',
categories: categories
},
y: {
tick: { tick: {
format: $("#chart").data("date-format") format: formatDuration
}
}
},
legend: {
show: false
}
});
}
// Draw chart for average time spent into each column
function drawTaskTimeColumn()
{
var metrics = $("#chart").data("metrics");
var plots = [$("#chart").data("label")];
var categories = [];
for (var i = 0; i < metrics.length; i++) {
plots.push(metrics[i].time_spent);
categories.push(metrics[i].title);
}
c3.generate({
data: {
columns: [plots],
type: 'bar'
},
bar: {
width: {
ratio: 0.5
}
},
axis: {
x: {
type: 'category',
categories: categories
},
y: {
tick: {
format: formatDuration
}
}
},
legend: {
show: false
}
});
}
// Draw lead and cycle time for the project
function drawLeadAndCycleTime()
{
var metrics = $("#chart").data("metrics");
var cycle = [$("#chart").data("label-cycle")];
var lead = [$("#chart").data("label-lead")];
var categories = [];
var types = {};
types[$("#chart").data("label-cycle")] = 'area';
types[$("#chart").data("label-lead")] = 'area-spline';
var colors = {};
colors[$("#chart").data("label-lead")] = '#afb42b';
colors[$("#chart").data("label-cycle")] = '#4e342e';
for (var i = 0; i < metrics.length; i++) {
cycle.push(parseInt(metrics[i].avg_cycle_time));
lead.push(parseInt(metrics[i].avg_lead_time));
categories.push(metrics[i].day);
}
c3.generate({
data: {
columns: [
lead,
cycle
],
types: types,
colors: colors
},
axis: {
x: {
type: 'category',
categories: categories
},
y: {
tick: {
format: formatDuration
} }
} }
} }
}); });
} }
function formatDuration(d)
{
if (d >= 86400) {
return Math.round(d/86400) + "d";
}
else if (d >= 3600) {
return Math.round(d/3600) + "h";
}
else if (d >= 60) {
return Math.round(d/60) + "m";
}
return d + "s";
}
jQuery(document).ready(function() { jQuery(document).ready(function() {
if (Kanboard.Exists("analytic-task-repartition")) { if (Kanboard.Exists("analytic-task-repartition")) {
@@ -182,6 +341,15 @@
else if (Kanboard.Exists("budget-chart")) { else if (Kanboard.Exists("budget-chart")) {
drawBudget(); drawBudget();
} }
else if (Kanboard.Exists("analytic-avg-time-column")) {
drawAvgTimeColumn();
}
else if (Kanboard.Exists("analytic-task-time-column")) {
drawTaskTimeColumn();
}
else if (Kanboard.Exists("analytic-lead-cycle-time")) {
drawLeadAndCycleTime();
}
}); });
})(); })();

View File

@@ -277,6 +277,15 @@ var Kanboard = (function() {
constrainInput: false constrainInput: false
}); });
// Datetime picker
$(".form-datetime").datetimepicker({
controlType: 'select',
oneLine: true,
dateFormat: 'yy-mm-dd',
// timeFormat: 'h:mm tt',
constrainInput: false
});
// Markdown Preview for textareas // Markdown Preview for textareas
$("#markdown-preview").click(Kanboard.MarkdownPreview); $("#markdown-preview").click(Kanboard.MarkdownPreview);
$("#markdown-write").click(Kanboard.MarkdownWriter); $("#markdown-write").click(Kanboard.MarkdownWriter);

View File

@@ -20,7 +20,7 @@
}); });
Mousetrap.bind("s", function() { Mousetrap.bind("s", function() {
stack_toggle(); window.location = $(".board-display-mode").attr("href");
}); });
Mousetrap.bind("c", function() { Mousetrap.bind("c", function() {
@@ -28,74 +28,6 @@
}); });
} }
// Collapse/Expand tasks
function stack_load_events()
{
$(".filter-expand-link").click(function(e) {
e.preventDefault();
stack_expand();
Kanboard.SetStorageItem(stack_key(), "expanded");
});
$(".filter-collapse-link").click(function(e) {
e.preventDefault();
stack_collapse();
Kanboard.SetStorageItem(stack_key(), "collapsed");
});
stack_show();
}
function stack_key()
{
var projectId = $('#board').data('project-id');
return "board_stacking_" + projectId;
}
function stack_collapse()
{
$(".filter-collapse").hide();
$(".task-board-collapsed").show();
$(".filter-expand").show();
$(".task-board-expanded").hide();
}
function stack_expand()
{
$(".filter-collapse").show();
$(".task-board-collapsed").hide();
$(".filter-expand").hide();
$(".task-board-expanded").show();
}
function stack_toggle()
{
var state = Kanboard.GetStorageItem(stack_key()) || "expanded";
if (state === "expanded") {
stack_collapse();
Kanboard.SetStorageItem(stack_key(), "collapsed");
}
else {
stack_expand();
Kanboard.SetStorageItem(stack_key(), "expanded");
}
}
function stack_show()
{
var state = Kanboard.GetStorageItem(stack_key()) || "expanded";
if (state === "expanded") {
stack_expand();
}
else {
stack_collapse();
}
}
// Setup the board // Setup the board
function board_load_events() function board_load_events()
{ {
@@ -243,7 +175,6 @@
$("#main").append(data); $("#main").append(data);
Kanboard.InitAfterAjax(); Kanboard.InitAfterAjax();
board_load_events(); board_load_events();
stack_show();
compactview_reload(); compactview_reload();
} }
}); });
@@ -263,7 +194,6 @@
Kanboard.InitAfterAjax(); Kanboard.InitAfterAjax();
board_unload_events(); board_unload_events();
board_load_events(); board_load_events();
stack_show();
compactview_reload(); compactview_reload();
} }
} }
@@ -312,7 +242,6 @@
if (Kanboard.Exists("board")) { if (Kanboard.Exists("board")) {
board_load_events(); board_load_events();
stack_load_events();
compactview_load_events(); compactview_load_events();
keyboard_shortcuts(); keyboard_shortcuts();
} }

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
"eluceo/ical": "*", "eluceo/ical": "*",
"erusev/parsedown" : "1.5.3", "erusev/parsedown" : "1.5.3",
"fabiang/xmpp" : "0.6.1", "fabiang/xmpp" : "0.6.1",
"fguillot/json-rpc" : "dev-master", "fguillot/json-rpc" : "1.0.0",
"fguillot/picodb" : "1.0.0", "fguillot/picodb" : "1.0.0",
"fguillot/simpleLogger" : "0.0.2", "fguillot/simpleLogger" : "0.0.2",
"fguillot/simple-validator" : "0.0.3", "fguillot/simple-validator" : "0.0.3",

13
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"hash": "1ef3f084b6c8651977b1bbc84d86cb69", "hash": "0048471872ea99cd30c53c0398c7d9f2",
"packages": [ "packages": [
{ {
"name": "christian-riesen/base32", "name": "christian-riesen/base32",
@@ -260,16 +260,16 @@
}, },
{ {
"name": "fguillot/json-rpc", "name": "fguillot/json-rpc",
"version": "dev-master", "version": "v1.0.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/fguillot/JsonRPC.git", "url": "https://github.com/fguillot/JsonRPC.git",
"reference": "ac3ddcf8f74777d72b8044d6d128f41aabe292f2" "reference": "5a11f1414780a200f09b78d20ab72b5cee4faa95"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/fguillot/JsonRPC/zipball/ac3ddcf8f74777d72b8044d6d128f41aabe292f2", "url": "https://api.github.com/repos/fguillot/JsonRPC/zipball/5a11f1414780a200f09b78d20ab72b5cee4faa95",
"reference": "ac3ddcf8f74777d72b8044d6d128f41aabe292f2", "reference": "5a11f1414780a200f09b78d20ab72b5cee4faa95",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -292,7 +292,7 @@
], ],
"description": "Simple Json-RPC client/server library that just works", "description": "Simple Json-RPC client/server library that just works",
"homepage": "https://github.com/fguillot/JsonRPC", "homepage": "https://github.com/fguillot/JsonRPC",
"time": "2015-07-01 19:26:37" "time": "2015-07-01 19:50:31"
}, },
{ {
"name": "fguillot/picodb", "name": "fguillot/picodb",
@@ -819,7 +819,6 @@
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": { "stability-flags": {
"fguillot/json-rpc": 20,
"swiftmailer/swiftmailer": 0, "swiftmailer/swiftmailer": 0,
"symfony/console": 0 "symfony/console": 0
}, },

View File

@@ -0,0 +1,24 @@
Analytics for tasks
===================
Each task have an analytics section available from the left menu in the task view.
Lead and cycle time
-------------------
![Lead and cycle time](http://kanboard.net/screenshots/documentation/task-lead-cycle-time.png)
- The lead time is the time between the task creation and the date of completion (task closed).
- The cycle time is the time between the start date and the date of completion.
- If the task is not closed the current time is used instead of the date of completion.
- If the start date is not specified, the cycle time is not calculated.
Note: You can configure an automatic action to define automatically the start date when you move a task to the column of your choice.
Time spent into each column
---------------------------
![Time spent into each column](http://kanboard.net/screenshots/documentation/time-into-each-column.png)
- This chart show the total time spent into each column for the task.
- The time spent is calculated until the task is closed.

View File

@@ -1,6 +1,8 @@
Analytics Analytics
========= =========
Each project have an analytics section. Depending how you are using Kanboard, you can see those reports:
User repartition User repartition
---------------- ----------------
@@ -20,7 +22,8 @@ Cumulative flow diagram
![Cumulative flow diagram](http://kanboard.net/screenshots/documentation/cfd.png) ![Cumulative flow diagram](http://kanboard.net/screenshots/documentation/cfd.png)
This chart show the number of tasks cumulatively for each column over the time. - This chart show the number of tasks cumulatively for each column over the time.
- Everyday, the total number of tasks is recorded for each column.
Note: You need to have at least 2 days of data to see the graph. Note: You need to have at least 2 days of data to see the graph.
@@ -30,13 +33,37 @@ Burndown chart
![Burndown chart](http://kanboard.net/screenshots/documentation/burndown-chart.png) ![Burndown chart](http://kanboard.net/screenshots/documentation/burndown-chart.png)
The [burn down chart](http://en.wikipedia.org/wiki/Burn_down_chart) is available for each project. The [burn down chart](http://en.wikipedia.org/wiki/Burn_down_chart) is available for each project.
This chart is a graphical representation of work left to do versus time.
Kanboard use the complexity or story point to generate this diagram. - This chart is a graphical representation of work left to do versus time.
- Kanboard use the complexity or story point to generate this diagram.
- Everyday, the sum of the story points for each column is calculated.
Average time spent into each column
-----------------------------------
![Average time spent into each column](http://kanboard.net/screenshots/documentation/average-time-spent-into-each-column.png)
This chart show the average time spent into each column for the last 1000 tasks.
- Kanboard use the task transitions to calculate the data.
- The time spent is calculated until the task is closed.
Average Lead and Cycle time
---------------------------
![Average time spent into each column](http://kanboard.net/screenshots/documentation/average-lead-cycle-time.png)
This chart show the average lead and cycle time for the last 1000 tasks over the time.
- The lead time is the time between the task creation and the date of completion.
- The cycle time is time between the specified start date of the task to completion date.
- If the task is not closed, the current time is used instead of the date of completion.
Those metrics are calculated and recorded everyday for the whole project.
Don't forget to run the daily job for stats calculation Don't forget to run the daily job for stats calculation
------------------------------------------------------- -------------------------------------------------------
To generate accurate analytics data, you should run the daily cronjob **project daily summaries** just before midnight. To generate accurate analytics data, you should run the daily cronjob **project daily statistics**.
[Read the documentation about Kanboard CLI](http://kanboard.net/documentation/cli) [Read the documentation about Kanboard CLI](cli.markdown)

View File

@@ -306,9 +306,19 @@ Response example:
"name": "API test", "name": "API test",
"is_active": "1", "is_active": "1",
"token": "", "token": "",
"last_modified": "1410263246", "last_modified": "1436119135",
"is_public": "0", "is_public": "0",
"description": "A sample project" "is_private": "0",
"is_everybody_allowed": "0",
"default_swimlane": "Default swimlane",
"show_default_swimlane": "1",
"description": "test",
"identifier": "",
"url": {
"board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
"calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
"list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
}
} }
} }
``` ```
@@ -345,9 +355,19 @@ Response example:
"name": "Test", "name": "Test",
"is_active": "1", "is_active": "1",
"token": "", "token": "",
"last_modified": "0", "last_modified": "1436119135",
"is_public": "0", "is_public": "0",
"description": "A sample project" "is_private": "0",
"is_everybody_allowed": "0",
"default_swimlane": "Default swimlane",
"show_default_swimlane": "1",
"description": "test",
"identifier": "",
"url": {
"board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
"calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
"list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
}
} }
} }
``` ```
@@ -366,7 +386,7 @@ Request example:
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "getAllProjects", "method": "getAllProjects",
"id": 134982303 "id": 2134420212
} }
``` ```
@@ -375,25 +395,26 @@ Response example:
```json ```json
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 134982303, "id": 2134420212,
"result": [ "result": [
{
"id": "2",
"name": "PHP client",
"is_active": "1",
"token": "",
"last_modified": "0",
"is_public": "0",
"description": "PHP client project"
},
{ {
"id": "1", "id": "1",
"name": "Test", "name": "API test",
"is_active": "1", "is_active": "1",
"token": "", "token": "",
"last_modified": "0", "last_modified": "1436119570",
"is_public": "0", "is_public": "0",
"description": "Test project" "is_private": "0",
"is_everybody_allowed": "0",
"default_swimlane": "Default swimlane",
"show_default_swimlane": "1",
"description": null,
"identifier": "",
"url": {
"board": "http:\/\/127.0.0.1:8000\/?controller=board&action=show&project_id=1",
"calendar": "http:\/\/127.0.0.1:8000\/?controller=calendar&action=show&project_id=1",
"list": "http:\/\/127.0.0.1:8000\/?controller=listing&action=show&project_id=1"
}
} }
] ]
} }
@@ -1970,8 +1991,9 @@ Response example:
"recurrence_timeframe": "0", "recurrence_timeframe": "0",
"recurrence_basedate": "0", "recurrence_basedate": "0",
"recurrence_parent": null, "recurrence_parent": null,
"recurrence_child": null "recurrence_child": null,
} "url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=1&project_id=1"
}
} }
``` ```
@@ -2033,7 +2055,8 @@ Response example:
"recurrence_timeframe": "0", "recurrence_timeframe": "0",
"recurrence_basedate": "0", "recurrence_basedate": "0",
"recurrence_parent": null, "recurrence_parent": null,
"recurrence_child": null "recurrence_child": null,
"url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=5&project_id=1"
} }
} }
``` ```
@@ -2097,7 +2120,8 @@ Response example:
"recurrence_timeframe": "0", "recurrence_timeframe": "0",
"recurrence_basedate": "0", "recurrence_basedate": "0",
"recurrence_parent": null, "recurrence_parent": null,
"recurrence_child": null "recurrence_child": null,
"url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=1&project_id=1"
}, },
{ {
"id": "2", "id": "2",
@@ -2128,7 +2152,8 @@ Response example:
"recurrence_timeframe": "0", "recurrence_timeframe": "0",
"recurrence_basedate": "0", "recurrence_basedate": "0",
"recurrence_parent": null, "recurrence_parent": null,
"recurrence_child": null "recurrence_child": null,
"url": "http:\/\/127.0.0.1:8000\/?controller=task&action=show&task_id=2&project_id=1"
}, },
... ...
] ]

View File

@@ -13,36 +13,35 @@ Usage
- Run the command `./kanboard` - Run the command `./kanboard`
```bash ```bash
$ ./kanboard
Kanboard version master Kanboard version master
Usage: Usage:
command [options] [arguments] command [options] [arguments]
Options: Options:
--help (-h) Display this help message -h, --help Display this help message
--quiet (-q) Do not output any message -q, --quiet Do not output any message
--verbose (-v|vv|vvv) Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug -V, --version Display this application version
--version (-V) Display this application version --ansi Force ANSI output
--ansi Force ANSI output --no-ansi Disable ANSI output
--no-ansi Disable ANSI output -n, --no-interaction Do not ask any interactive question
--no-interaction (-n) Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
Available commands: Available commands:
help Displays help for a command help Displays help for a command
list Lists commands list Lists commands
export export
export:daily-project-summary Daily project summary CSV export (number of tasks per column and per day) export:daily-project-column-stats Daily project column stats CSV export (number of tasks per column and per day)
export:subtasks Subtasks CSV export export:subtasks Subtasks CSV export
export:tasks Tasks CSV export export:tasks Tasks CSV export
export:transitions Task transitions CSV export export:transitions Task transitions CSV export
locale locale
locale:compare Compare application translations with the fr_FR locale locale:compare Compare application translations with the fr_FR locale
locale:sync Synchronize all translations based on the fr_FR locale locale:sync Synchronize all translations based on the fr_FR locale
notification notification
notification:overdue-tasks Send notifications for overdue tasks notification:overdue-tasks Send notifications for overdue tasks
projects projects
projects:daily-summary Calculate daily summary data for all projects projects:daily-stats Calculate daily statistics for all projects
``` ```
Available commands Available commands
@@ -97,13 +96,13 @@ Example:
The exported data will be printed on the standard output: The exported data will be printed on the standard output:
```bash ```bash
./kanboard export:daily-project-summary <project_id> <start_date> <end_date> ./kanboard export:daily-project-column-stats <project_id> <start_date> <end_date>
``` ```
Example: Example:
```bash ```bash
./kanboard export:daily-project-summary 1 2014-10-01 2014-11-30 > /tmp/my_custom_export.csv ./kanboard export:daily-project-column-stats 1 2014-10-01 2014-11-30 > /tmp/my_custom_export.csv
``` ```
### Send notifications for overdue tasks ### Send notifications for overdue tasks
@@ -133,12 +132,12 @@ Cronjob example:
0 8 * * * cd /path/to/kanboard && ./kanboard notification:overdue-tasks >/dev/null 2>&1 0 8 * * * cd /path/to/kanboard && ./kanboard notification:overdue-tasks >/dev/null 2>&1
``` ```
### Run daily project summaries calculation ### Run daily project stats calculation
You can add a background task that calculate the daily project summaries everyday: You can add a background task to calculate the project statistics everyday:
```bash ```bash
$ ./kanboard projects:daily-summary $ ./kanboard projects:daily-stats
Run calculation for Project #0 Run calculation for Project #0
Run calculation for Project #1 Run calculation for Project #1
Run calculation for Project #10 Run calculation for Project #10

View File

@@ -7,6 +7,8 @@ This feature allow you to import Kanboard tasks in almost any calendar program (
Calendar subscriptions are **read-only** access, you cannot create tasks from an external calendar software. Calendar subscriptions are **read-only** access, you cannot create tasks from an external calendar software.
The Calendar feed export follow the iCal standard. The Calendar feed export follow the iCal standard.
Note: Only tasks within the date range of -2 months to +6 months are exported to the iCalendar feed.
Project calendars Project calendars
----------------- -----------------

View File

@@ -33,6 +33,7 @@ Using Kanboard
- [Recurring tasks](recurring-tasks.markdown) - [Recurring tasks](recurring-tasks.markdown)
- [Create tasks by email](create-tasks-by-email.markdown) - [Create tasks by email](create-tasks-by-email.markdown)
- [Subtasks](subtasks.markdown) - [Subtasks](subtasks.markdown)
- [Analytics for tasks](analytics-tasks.markdown)
### Working with users ### Working with users

View File

@@ -26,3 +26,13 @@ This feature use the [Incoming webhook](https://api.slack.com/incoming-webhooks)
3. Copy the webhook url to the Kanboard settings page: **Settings > Integrations > Slack** or **Project settings > Integrations > Slack** 3. Copy the webhook url to the Kanboard settings page: **Settings > Integrations > Slack** or **Project settings > Integrations > Slack**
Now, Kanboard events will be sent to the Slack channel. Now, Kanboard events will be sent to the Slack channel.
### Overriding Channel (Optional)
Optionally you can override the channel, private group or send direct messages by filling up **Channel/Group/User** text box. Leaving it empty will post to the channel configured during webhook configuration.
Examples:
- Send messages to another channel: **#mychannel1**
- Send messages to a private group: **#myprivategroup1**
- Send messages directly to someone: **@anotheruser1**

View File

@@ -12,8 +12,8 @@ $application = new Application('Kanboard', APP_VERSION);
$application->add(new Console\TaskOverdueNotification($container)); $application->add(new Console\TaskOverdueNotification($container));
$application->add(new Console\SubtaskExport($container)); $application->add(new Console\SubtaskExport($container));
$application->add(new Console\TaskExport($container)); $application->add(new Console\TaskExport($container));
$application->add(new Console\ProjectDailySummaryCalculation($container)); $application->add(new Console\ProjectDailyStatsCalculation($container));
$application->add(new Console\ProjectDailySummaryExport($container)); $application->add(new Console\ProjectDailyColumnStatsExport($container));
$application->add(new Console\TransitionExport($container)); $application->add(new Console\TransitionExport($container));
$application->add(new Console\LocaleSync($container)); $application->add(new Console\LocaleSync($container));
$application->add(new Console\LocaleComparator($container)); $application->add(new Console\LocaleComparator($container));

View File

@@ -3,11 +3,11 @@
require __DIR__.'/../app/common.php'; require __DIR__.'/../app/common.php';
use Model\ProjectDailySummary; use Model\ProjectDailyColumnStats;
use Model\TaskCreation; use Model\TaskCreation;
use Model\TaskStatus; use Model\TaskStatus;
$pds = new ProjectDailySummary($container); $pds = new ProjectDailyColumnStats($container);
$taskCreation = new TaskCreation($container); $taskCreation = new TaskCreation($container);
$taskStatus = new TaskStatus($container); $taskStatus = new TaskStatus($container);

View File

@@ -3,11 +3,11 @@
require __DIR__.'/../app/common.php'; require __DIR__.'/../app/common.php';
use Model\ProjectDailySummary; use Model\ProjectDailyColumnStats;
use Model\TaskCreation; use Model\TaskCreation;
use Model\TaskPosition; use Model\TaskPosition;
$pds = new ProjectDailySummary($container); $pds = new ProjectDailyColumnStats($container);
$taskCreation = new TaskCreation($container); $taskCreation = new TaskCreation($container);
$taskPosition = new TaskPosition($container); $taskPosition = new TaskPosition($container);

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env php
<?php
require __DIR__.'/../app/common.php';
use Model\Project;
use Model\ProjectDailyStats;
$p = new Project($container);
$pds = new ProjectDailyStats($container);
$p->create(array('name' => 'Test Lead/Cycle time'));
$container['db']->table('tasks')->insert(array(
'title' => 'Lead time = 4d | Cycle time = 3d',
'date_creation' => strtotime('-7 days'),
'date_started' => strtotime('-6 days'),
'date_completed' => strtotime('-3 days'),
'is_active' => 0,
'project_id' => 1,
'column_id' => 1,
));
$container['db']->table('tasks')->insert(array(
'title' => 'Lead time = 1d | Cycle time = 1d',
'date_creation' => strtotime('-7 days'),
'date_started' => strtotime('-7 days'),
'date_completed' => strtotime('-6 days'),
'is_active' => 0,
'project_id' => 1,
'column_id' => 1,
));
$pds->updateTotals(1, date('Y-m-d', strtotime('-6 days')));
$container['db']->table('tasks')->insert(array(
'title' => 'Lead time = 7d | Cycle time = 5d',
'date_creation' => strtotime('-7 days'),
'date_started' => strtotime('-5 days'),
'date_completed' => strtotime('today'),
'is_active' => 0,
'project_id' => 1,
'column_id' => 1,
));
$pds->updateTotals(1, date('Y-m-d', strtotime('-5 days')));
$container['db']->table('tasks')->insert(array(
'title' => 'Lead time = 1d | Cycle time = 0',
'date_creation' => strtotime('-3 days'),
'date_started' => 0,
'date_completed' => 0,
'is_active' => 0,
'project_id' => 1,
'column_id' => 1,
));
$pds->updateTotals(1, date('Y-m-d', strtotime('-4 days')));
$container['db']->table('tasks')->insert(array(
'title' => 'Lead time = 1d | Cycle time = 1d',
'date_creation' => strtotime('-3 days'),
'date_started' => strtotime('-3 days'),
'date_completed' => 0,
'is_active' => 0,
'project_id' => 1,
'column_id' => 1,
));
$pds->updateTotals(1, date('Y-m-d', strtotime('-3 days')));

View File

@@ -2,10 +2,10 @@
print_css="print links table board task comment subtask markdown" print_css="print links table board task comment subtask markdown"
app_css="base links title table form button alert tooltip header board task comment subtask markdown listing activity dashboard pagination popover confirm sidebar responsive dropdown screenshot filters" app_css="base links title table form button alert tooltip header board task comment subtask markdown listing activity dashboard pagination popover confirm sidebar responsive dropdown screenshot filters"
vendor_css="jquery-ui.min chosen.min fullcalendar.min font-awesome.min c3.min" vendor_css="jquery-ui.min jquery-ui-timepicker-addon.min chosen.min fullcalendar.min font-awesome.min c3.min"
app_js="base board calendar analytic swimlane screenshot" app_js="base board calendar analytic swimlane screenshot"
vendor_js="jquery-1.11.1.min jquery-ui.min jquery.ui.touch-punch.min chosen.jquery.min dropit.min moment.min fullcalendar.min mousetrap.min mousetrap-global-bind.min app.min" vendor_js="jquery-1.11.1.min jquery-ui.min jquery-ui-timepicker-addon.min jquery.ui.touch-punch.min chosen.jquery.min dropit.min moment.min fullcalendar.min mousetrap.min mousetrap-global-bind.min app.min"
lang_js="da de es fi fr hu it ja nl pl pt-br ru sv sr th tr zh-cn" lang_js="da de es fi fr hu it ja nl pl pt-br ru sv sr th tr zh-cn"
function merge_css { function merge_css {

View File

@@ -63,6 +63,9 @@ class Api extends PHPUnit_Framework_TestCase
if ($projects) { if ($projects) {
foreach ($projects as $project) { foreach ($projects as $project) {
$this->assertEquals('http://127.0.0.1:8000/?controller=board&action=show&project_id='.$project['id'], $project['url']['board']);
$this->assertEquals('http://127.0.0.1:8000/?controller=calendar&action=show&project_id='.$project['id'], $project['url']['calendar']);
$this->assertEquals('http://127.0.0.1:8000/?controller=listing&action=show&project_id='.$project['id'], $project['url']['list']);
$this->assertTrue($this->client->removeProject($project['id'])); $this->assertTrue($this->client->removeProject($project['id']));
} }
} }
@@ -80,6 +83,9 @@ class Api extends PHPUnit_Framework_TestCase
$project = $this->client->getProjectById(1); $project = $this->client->getProjectById(1);
$this->assertNotEmpty($project); $this->assertNotEmpty($project);
$this->assertEquals(1, $project['id']); $this->assertEquals(1, $project['id']);
$this->assertEquals('http://127.0.0.1:8000/?controller=board&action=show&project_id='.$project['id'], $project['url']['board']);
$this->assertEquals('http://127.0.0.1:8000/?controller=calendar&action=show&project_id='.$project['id'], $project['url']['calendar']);
$this->assertEquals('http://127.0.0.1:8000/?controller=listing&action=show&project_id='.$project['id'], $project['url']['list']);
} }
public function testGetProjectByName() public function testGetProjectByName()
@@ -87,6 +93,9 @@ class Api extends PHPUnit_Framework_TestCase
$project = $this->client->getProjectByName('API test'); $project = $this->client->getProjectByName('API test');
$this->assertNotEmpty($project); $this->assertNotEmpty($project);
$this->assertEquals(1, $project['id']); $this->assertEquals(1, $project['id']);
$this->assertEquals('http://127.0.0.1:8000/?controller=board&action=show&project_id='.$project['id'], $project['url']['board']);
$this->assertEquals('http://127.0.0.1:8000/?controller=calendar&action=show&project_id='.$project['id'], $project['url']['calendar']);
$this->assertEquals('http://127.0.0.1:8000/?controller=listing&action=show&project_id='.$project['id'], $project['url']['list']);
$project = $this->client->getProjectByName(array('name' => 'API test')); $project = $this->client->getProjectByName(array('name' => 'API test'));
$this->assertNotEmpty($project); $this->assertNotEmpty($project);
@@ -97,6 +106,18 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertNull($project); $this->assertNull($project);
} }
public function testGetAllProjects()
{
$projects = $this->client->getAllProjects();
$this->assertNotEmpty($projects);
foreach ($projects as $project) {
$this->assertEquals('http://127.0.0.1:8000/?controller=board&action=show&project_id='.$project['id'], $project['url']['board']);
$this->assertEquals('http://127.0.0.1:8000/?controller=calendar&action=show&project_id='.$project['id'], $project['url']['calendar']);
$this->assertEquals('http://127.0.0.1:8000/?controller=listing&action=show&project_id='.$project['id'], $project['url']['list']);
}
}
public function testUpdateProject() public function testUpdateProject()
{ {
$project = $this->client->getProjectById(1); $project = $this->client->getProjectById(1);
@@ -385,6 +406,7 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertNotFalse($task); $this->assertNotFalse($task);
$this->assertTrue(is_array($task)); $this->assertTrue(is_array($task));
$this->assertEquals('Task #1', $task['title']); $this->assertEquals('Task #1', $task['title']);
$this->assertEquals('http://127.0.0.1:8000/?controller=task&action=show&task_id='.$task['id'].'&project_id='.$task['project_id'], $task['url']);
} }
public function testGetAllTasks() public function testGetAllTasks()
@@ -394,6 +416,7 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertNotFalse($tasks); $this->assertNotFalse($tasks);
$this->assertTrue(is_array($tasks)); $this->assertTrue(is_array($tasks));
$this->assertEquals('Task #1', $tasks[0]['title']); $this->assertEquals('Task #1', $tasks[0]['title']);
$this->assertEquals('http://127.0.0.1:8000/?controller=task&action=show&task_id='.$tasks[0]['id'].'&project_id='.$tasks[0]['project_id'], $tasks[0]['url']);
$tasks = $this->client->getAllTasks(2, 0); $tasks = $this->client->getAllTasks(2, 0);
@@ -1030,5 +1053,6 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertNotEmpty($task); $this->assertNotEmpty($task);
$this->assertEquals('Task with external ticket number', $task['title']); $this->assertEquals('Task with external ticket number', $task['title']);
$this->assertEquals('TICKET-1234', $task['reference']); $this->assertEquals('TICKET-1234', $task['reference']);
$this->assertEquals('http://127.0.0.1:8000/?controller=task&action=show&task_id='.$task['id'].'&project_id='.$task['project_id'], $task['url']);
} }
} }

View File

@@ -58,6 +58,8 @@ abstract class Base extends PHPUnit_Framework_TestCase
public function setUp() public function setUp()
{ {
date_default_timezone_set('UTC');
if (DB_DRIVER === 'mysql') { if (DB_DRIVER === 'mysql') {
$pdo = new PDO('mysql:host='.DB_HOSTNAME, DB_USERNAME, DB_PASSWORD); $pdo = new PDO('mysql:host='.DB_HOSTNAME, DB_USERNAME, DB_PASSWORD);
$pdo->exec('DROP DATABASE '.DB_NAME); $pdo->exec('DROP DATABASE '.DB_NAME);

View File

@@ -56,6 +56,12 @@ class DateParserTest extends Base
$this->assertEquals('2014-03-05', date('Y-m-d', $d->getTimestamp('2014-03-05'))); $this->assertEquals('2014-03-05', date('Y-m-d', $d->getTimestamp('2014-03-05')));
$this->assertEquals('2014-03-05', date('Y-m-d', $d->getTimestamp('2014_03_05'))); $this->assertEquals('2014-03-05', date('Y-m-d', $d->getTimestamp('2014_03_05')));
$this->assertEquals('2014-03-05', date('Y-m-d', $d->getTimestamp('03/05/2014'))); $this->assertEquals('2014-03-05', date('Y-m-d', $d->getTimestamp('03/05/2014')));
$this->assertEquals('2014-03-25 17:18', date('Y-m-d H:i', $d->getTimestamp('03/25/2014 5:18 pm')));
$this->assertEquals('2014-03-25 05:18', date('Y-m-d H:i', $d->getTimestamp('03/25/2014 5:18 am')));
$this->assertEquals('2014-03-25 05:18', date('Y-m-d H:i', $d->getTimestamp('03/25/2014 5:18am')));
$this->assertEquals('2014-03-25 23:14', date('Y-m-d H:i', $d->getTimestamp('03/25/2014 23:14')));
$this->assertEquals('2014-03-29 23:14', date('Y-m-d H:i', $d->getTimestamp('2014_03_29 23:14')));
$this->assertEquals('2014-03-29 23:14', date('Y-m-d H:i', $d->getTimestamp('2014-03-29 23:14')));
} }
public function testConvert() public function testConvert()

View File

@@ -2,13 +2,13 @@
require_once __DIR__.'/Base.php'; require_once __DIR__.'/Base.php';
use Helper\Datetime; use Helper\Dt;
class DatetimeHelperTest extends Base class DatetimeHelperTest extends Base
{ {
public function testAge() public function testAge()
{ {
$h = new Datetime($this->container); $h = new Dt($this->container);
$this->assertEquals('&lt;15m', $h->age(0, 30)); $this->assertEquals('&lt;15m', $h->age(0, 30));
$this->assertEquals('&lt;30m', $h->age(0, 1000)); $this->assertEquals('&lt;30m', $h->age(0, 1000));
@@ -20,7 +20,7 @@ class DatetimeHelperTest extends Base
public function testGetDayHours() public function testGetDayHours()
{ {
$h = new Datetime($this->container); $h = new Dt($this->container);
$slots = $h->getDayHours(); $slots = $h->getDayHours();
@@ -36,7 +36,7 @@ class DatetimeHelperTest extends Base
public function testGetWeekDays() public function testGetWeekDays()
{ {
$h = new Datetime($this->container); $h = new Dt($this->container);
$slots = $h->getWeekDays(); $slots = $h->getWeekDays();
@@ -48,7 +48,7 @@ class DatetimeHelperTest extends Base
public function testGetWeekDay() public function testGetWeekDay()
{ {
$h = new Datetime($this->container); $h = new Dt($this->container);
$this->assertEquals('Monday', $h->getWeekDay(1)); $this->assertEquals('Monday', $h->getWeekDay(1));
$this->assertEquals('Sunday', $h->getWeekDay(7)); $this->assertEquals('Sunday', $h->getWeekDay(7));

View File

@@ -3,17 +3,17 @@
require_once __DIR__.'/Base.php'; require_once __DIR__.'/Base.php';
use Model\Project; use Model\Project;
use Model\ProjectDailySummary; use Model\ProjectDailyColumnStats;
use Model\Task; use Model\Task;
use Model\TaskCreation; use Model\TaskCreation;
use Model\TaskStatus; use Model\TaskStatus;
class ProjectDailySummaryTest extends Base class ProjectDailyColumnStatsTest extends Base
{ {
public function testUpdateTotals() public function testUpdateTotals()
{ {
$p = new Project($this->container); $p = new Project($this->container);
$pds = new ProjectDailySummary($this->container); $pds = new ProjectDailyColumnStats($this->container);
$tc = new TaskCreation($this->container); $tc = new TaskCreation($this->container);
$ts = new TaskStatus($this->container); $ts = new TaskStatus($this->container);

View File

@@ -175,6 +175,28 @@ class TaskCreationTest extends Base
$this->assertEquals(1, $task['creator_id']); $this->assertEquals(1, $task['creator_id']);
} }
public function testThatCreatorIsDefined()
{
$p = new Project($this->container);
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$_SESSION = array();
$_SESSION['user']['id'] = 1;
$this->assertEquals(1, $p->create(array('name' => 'test')));
$this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test')));
$task = $tf->getById(1);
$this->assertNotEmpty($task);
$this->assertNotFalse($task);
$this->assertEquals(1, $task['id']);
$this->assertEquals(1, $task['creator_id']);
$_SESSION = array();
}
public function testColumnId() public function testColumnId()
{ {
$p = new Project($this->container); $p = new Project($this->container);
@@ -284,29 +306,35 @@ class TaskCreationTest extends Base
public function testDateStarted() public function testDateStarted()
{ {
$date = '2014-11-23';
$timestamp = strtotime('+2days');
$p = new Project($this->container); $p = new Project($this->container);
$tc = new TaskCreation($this->container); $tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container); $tf = new TaskFinder($this->container);
$this->assertEquals(1, $p->create(array('name' => 'test'))); $this->assertEquals(1, $p->create(array('name' => 'test')));
$this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_started' => $date)));
$this->assertEquals(2, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_started' => $timestamp))); // Set only a date
$this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_started' => '2014-11-24')));
$task = $tf->getById(1); $task = $tf->getById(1);
$this->assertNotEmpty($task); $this->assertEquals('2014-11-24 '.date('H:i'), date('Y-m-d H:i', $task['date_started']));
$this->assertNotFalse($task);
$this->assertEquals(1, $task['id']); // Set a datetime
$this->assertEquals($date, date('Y-m-d', $task['date_started'])); $this->assertEquals(2, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_started' => '2014-11-24 16:25')));
$task = $tf->getById(2); $task = $tf->getById(2);
$this->assertNotEmpty($task); $this->assertEquals('2014-11-24 16:25', date('Y-m-d H:i', $task['date_started']));
$this->assertNotFalse($task);
$this->assertEquals(2, $task['id']); // Set a datetime
$this->assertEquals($timestamp, $task['date_started']); $this->assertEquals(3, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_started' => '2014-11-24 6:25pm')));
$task = $tf->getById(3);
$this->assertEquals('2014-11-24 18:25', date('Y-m-d H:i', $task['date_started']));
// Set a timestamp
$this->assertEquals(4, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_started' => time())));
$task = $tf->getById(4);
$this->assertEquals(time(), $task['date_started'], '', 1);
} }
public function testTime() public function testTime()

View File

@@ -15,6 +15,36 @@ use Model\Swimlane;
class TaskDuplicationTest extends Base class TaskDuplicationTest extends Base
{ {
public function testThatDuplicateDefineCreator()
{
$td = new TaskDuplication($this->container);
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container);
$this->assertEquals(1, $p->create(array('name' => 'test1')));
$this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1)));
$task = $tf->getById(1);
$this->assertNotEmpty($task);
$this->assertEquals(1, $task['position']);
$this->assertEquals(1, $task['project_id']);
$this->assertEquals(0, $task['creator_id']);
$_SESSION = array();
$_SESSION['user']['id'] = 1;
// We duplicate our task
$this->assertEquals(2, $td->duplicate(1));
// Check the values of the duplicated task
$task = $tf->getById(2);
$this->assertNotEmpty($task);
$this->assertEquals(1, $task['creator_id']);
$_SESSION = array();
}
public function testDuplicateSameProject() public function testDuplicateSameProject()
{ {
$td = new TaskDuplication($this->container); $td = new TaskDuplication($this->container);

View File

@@ -9,9 +9,60 @@ use Model\TaskCreation;
use Model\DateParser; use Model\DateParser;
use Model\Category; use Model\Category;
use Model\Subtask; use Model\Subtask;
use Model\Config;
class TaskFilterTest extends Base class TaskFilterTest extends Base
{ {
public function testIcalEventsWithCreatorAndDueDate()
{
$dp = new DateParser($this->container);
$p = new Project($this->container);
$tc = new TaskCreation($this->container);
$tf = new TaskFilter($this->container);
$this->assertEquals(1, $p->create(array('name' => 'test')));
$this->assertNotFalse($tc->create(array('project_id' => 1, 'title' => 'task1', 'creator_id' => 1, 'date_due' => $dp->getTimestampFromIsoFormat('-2 days'))));
$events = $tf->create()->filterByDueDateRange(strtotime('-1 month'), strtotime('+1 month'))->addAllDayIcalEvents();
$ics = $events->render();
$this->assertContains('UID:task-#1-date_due', $ics);
$this->assertContains('DTSTART;TZID=UTC;VALUE=DATE:'.date('Ymd', strtotime('-2 days')), $ics);
$this->assertContains('DTEND;TZID=UTC;VALUE=DATE:'.date('Ymd', strtotime('-2 days')), $ics);
$this->assertContains('URL:http://localhost/?controller=task&action=show&task_id=1&project_id=1', $ics);
$this->assertContains('SUMMARY:#1 task1', $ics);
$this->assertContains('ATTENDEE:MAILTO:admin@kanboard.local', $ics);
$this->assertContains('X-MICROSOFT-CDO-ALLDAYEVENT:TRUE', $ics);
}
public function testIcalEventsWithAssigneeAndDueDate()
{
$dp = new DateParser($this->container);
$p = new Project($this->container);
$tc = new TaskCreation($this->container);
$tf = new TaskFilter($this->container);
$u = new User($this->container);
$c = new Config($this->container);
$this->assertNotFalse($c->save(array('application_url' => 'http://kb/')));
$this->assertEquals('http://kb/', $c->get('application_url'));
$this->assertNotFalse($u->update(array('id' => 1, 'email' => 'bob@localhost')));
$this->assertEquals(1, $p->create(array('name' => 'test')));
$this->assertNotFalse($tc->create(array('project_id' => 1, 'title' => 'task1', 'owner_id' => 1, 'date_due' => $dp->getTimestampFromIsoFormat('+5 days'))));
$events = $tf->create()->filterByDueDateRange(strtotime('-1 month'), strtotime('+1 month'))->addAllDayIcalEvents();
$ics = $events->render();
$this->assertContains('UID:task-#1-date_due', $ics);
$this->assertContains('DTSTART;TZID=UTC;VALUE=DATE:'.date('Ymd', strtotime('+5 days')), $ics);
$this->assertContains('DTEND;TZID=UTC;VALUE=DATE:'.date('Ymd', strtotime('+5 days')), $ics);
$this->assertContains('URL:http://kb/?controller=task&action=show&task_id=1&project_id=1', $ics);
$this->assertContains('SUMMARY:#1 task1', $ics);
$this->assertContains('ORGANIZER:MAILTO:bob@localhost', $ics);
$this->assertContains('X-MICROSOFT-CDO-ALLDAYEVENT:TRUE', $ics);
}
public function testSearchWithEmptyResult() public function testSearchWithEmptyResult()
{ {
$dp = new DateParser($this->container); $dp = new DateParser($this->container);
@@ -441,19 +492,13 @@ class TaskFilterTest extends Base
$this->assertEquals('my task title is awesome', $tasks[0]['title']); $this->assertEquals('my task title is awesome', $tasks[0]['title']);
$this->assertEquals('my task title is amazing', $tasks[1]['title']); $this->assertEquals('my task title is amazing', $tasks[1]['title']);
$tf->search('assignee:nobody'); $tf->search('assignee:nobody');
$tasks = $tf->findAll(); $tasks = $tf->findAll();
$this->assertNotEmpty($tasks); $this->assertNotEmpty($tasks);
$this->assertCount(1, $tasks); $this->assertCount(1, $tasks);
$this->assertEquals('my task title is amazing', $tasks[0]['title']); $this->assertEquals('my task title is amazing', $tasks[0]['title']);
} }
public function testCopy() public function testCopy()
{ {
$tf = new TaskFilter($this->container); $tf = new TaskFilter($this->container);

View File

@@ -202,15 +202,29 @@ class TaskModificationTest extends Base
$task = $tf->getById(1); $task = $tf->getById(1);
$this->assertEquals(0, $task['date_started']); $this->assertEquals(0, $task['date_started']);
// Set only a date
$this->assertTrue($tm->update(array('id' => 1, 'date_started' => '2014-11-24'))); $this->assertTrue($tm->update(array('id' => 1, 'date_started' => '2014-11-24')));
$task = $tf->getById(1); $task = $tf->getById(1);
$this->assertEquals('2014-11-24', date('Y-m-d', $task['date_started'])); $this->assertEquals('2014-11-24 '.date('H:i'), date('Y-m-d H:i', $task['date_started']));
// Set a datetime
$this->assertTrue($tm->update(array('id' => 1, 'date_started' => '2014-11-24 16:25')));
$task = $tf->getById(1);
$this->assertEquals('2014-11-24 16:25', date('Y-m-d H:i', $task['date_started']));
// Set a datetime
$this->assertTrue($tm->update(array('id' => 1, 'date_started' => '2014-11-24 6:25pm')));
$task = $tf->getById(1);
$this->assertEquals('2014-11-24 18:25', date('Y-m-d H:i', $task['date_started']));
// Set a timestamp
$this->assertTrue($tm->update(array('id' => 1, 'date_started' => time()))); $this->assertTrue($tm->update(array('id' => 1, 'date_started' => time())));
$task = $tf->getById(1); $task = $tf->getById(1);
$this->assertEquals(date('Y-m-d'), date('Y-m-d', $task['date_started'])); $this->assertEquals(time(), $task['date_started'], '', 1);
} }
public function testChangeTimeEstimated() public function testChangeTimeEstimated()

View File

@@ -11,49 +11,88 @@ use Model\Swimlane;
class TaskPositionTest extends Base class TaskPositionTest extends Base
{ {
public function testCalculatePositionBadPosition() public function testMoveTaskToWrongPosition()
{ {
$tp = new TaskPosition($this->container); $tp = new TaskPosition($this->container);
$tc = new TaskCreation($this->container); $tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container); $p = new Project($this->container);
$this->assertEquals(1, $p->create(array('name' => 'Project #1'))); $this->assertEquals(1, $p->create(array('name' => 'Project #1')));
$this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1)));
$this->assertFalse($tp->calculatePositions(1, 1, 2, 0)); $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1)));
$this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1)));
// We move the task 2 to the position 0
$this->assertFalse($tp->movePosition(1, 1, 3, 0));
// Check tasks position
$task = $tf->getById(1);
$this->assertEquals(1, $task['id']);
$this->assertEquals(1, $task['column_id']);
$this->assertEquals(1, $task['position']);
$task = $tf->getById(2);
$this->assertEquals(2, $task['id']);
$this->assertEquals(1, $task['column_id']);
$this->assertEquals(2, $task['position']);
} }
public function testCalculatePositionBadColumn() public function testMoveTaskToGreaterPosition()
{ {
$tp = new TaskPosition($this->container); $tp = new TaskPosition($this->container);
$tc = new TaskCreation($this->container); $tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container); $p = new Project($this->container);
$this->assertEquals(1, $p->create(array('name' => 'Project #1'))); $this->assertEquals(1, $p->create(array('name' => 'Project #1')));
$this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1)));
$this->assertFalse($tp->calculatePositions(1, 1, 10, 1)); $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1)));
$this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1)));
// We move the task 2 to the position 42
$this->assertTrue($tp->movePosition(1, 1, 1, 42));
// Check tasks position
$task = $tf->getById(1);
$this->assertEquals(1, $task['id']);
$this->assertEquals(1, $task['column_id']);
$this->assertEquals(2, $task['position']);
$task = $tf->getById(2);
$this->assertEquals(2, $task['id']);
$this->assertEquals(1, $task['column_id']);
$this->assertEquals(1, $task['position']);
} }
public function testCalculatePositions() public function testMoveTaskToEmptyColumn()
{ {
$tp = new TaskPosition($this->container); $tp = new TaskPosition($this->container);
$tc = new TaskCreation($this->container); $tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container); $p = new Project($this->container);
$this->assertEquals(1, $p->create(array('name' => 'Project #1'))); $this->assertEquals(1, $p->create(array('name' => 'Project #1')));
$this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1)));
$positions = $tp->calculatePositions(1, 1, 2, 1); $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1)));
$this->assertNotFalse($positions); $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1)));
$this->assertNotEmpty($positions);
$this->assertEmpty($positions[1]); // We move the task 2 to the column 3
$this->assertEmpty($positions[3]); $this->assertTrue($tp->movePosition(1, 1, 3, 1));
$this->assertEmpty($positions[4]);
$this->assertEquals(array(1), $positions[2]); // Check tasks position
$task = $tf->getById(1);
$this->assertEquals(1, $task['id']);
$this->assertEquals(3, $task['column_id']);
$this->assertEquals(1, $task['position']);
$task = $tf->getById(2);
$this->assertEquals(2, $task['id']);
$this->assertEquals(1, $task['column_id']);
$this->assertEquals(1, $task['position']);
} }
public function testMoveTaskWithColumnThatNotChange() public function testMoveTaskToAnotherColumn()
{ {
$tp = new TaskPosition($this->container); $tp = new TaskPosition($this->container);
$tc = new TaskCreation($this->container); $tc = new TaskCreation($this->container);
@@ -116,40 +155,6 @@ class TaskPositionTest extends Base
$this->assertEquals(3, $task['position']); $this->assertEquals(3, $task['position']);
} }
public function testMoveTaskWithBadPreviousPosition()
{
$tp = new TaskPosition($this->container);
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$p = new Project($this->container);
$this->assertEquals(1, $p->create(array('name' => 'Project #1')));
$this->assertEquals(1, $this->container['db']->table('tasks')->insert(array('title' => 'A', 'column_id' => 1, 'project_id' => 1, 'position' => 1)));
// Both tasks have the same position
$this->assertEquals(2, $this->container['db']->table('tasks')->insert(array('title' => 'B', 'column_id' => 2, 'project_id' => 1, 'position' => 1)));
$this->assertEquals(3, $this->container['db']->table('tasks')->insert(array('title' => 'C', 'column_id' => 2, 'project_id' => 1, 'position' => 1)));
// Move the first column to the last position of the 2nd column
$this->assertTrue($tp->movePosition(1, 1, 2, 3));
// Check tasks position
$task = $tf->getById(2);
$this->assertEquals(2, $task['id']);
$this->assertEquals(2, $task['column_id']);
$this->assertEquals(1, $task['position']);
$task = $tf->getById(3);
$this->assertEquals(3, $task['id']);
$this->assertEquals(2, $task['column_id']);
$this->assertEquals(2, $task['position']);
$task = $tf->getById(1);
$this->assertEquals(1, $task['id']);
$this->assertEquals(2, $task['column_id']);
$this->assertEquals(3, $task['position']);
}
public function testMoveTaskTop() public function testMoveTaskTop()
{ {
$tp = new TaskPosition($this->container); $tp = new TaskPosition($this->container);

View File

@@ -38,22 +38,26 @@ class UrlHelperTest extends Base
{ {
$h = new Url($this->container); $h = new Url($this->container);
$this->assertEquals('http://localhost/', $h->server());
$_SERVER['PHP_SELF'] = '/'; $_SERVER['PHP_SELF'] = '/';
$_SERVER['SERVER_NAME'] = 'localhost'; $_SERVER['SERVER_NAME'] = 'kb';
$_SERVER['SERVER_PORT'] = 1234; $_SERVER['SERVER_PORT'] = 1234;
$this->assertEquals('http://localhost:1234/', $h->server()); $this->assertEquals('http://kb:1234/', $h->server());
} }
public function testBase() public function testBase()
{ {
$h = new Url($this->container); $h = new Url($this->container);
$this->assertEquals('http://localhost/', $h->base());
$_SERVER['PHP_SELF'] = '/'; $_SERVER['PHP_SELF'] = '/';
$_SERVER['SERVER_NAME'] = 'localhost'; $_SERVER['SERVER_NAME'] = 'kb';
$_SERVER['SERVER_PORT'] = 1234; $_SERVER['SERVER_PORT'] = 1234;
$this->assertEquals('http://localhost:1234/', $h->base()); $this->assertEquals('http://kb:1234/', $h->base());
$c = new Config($this->container); $c = new Config($this->container);
$c->save(array('application_url' => 'https://mykanboard/')); $c->save(array('application_url' => 'https://mykanboard/'));