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

View File

@ -12,17 +12,17 @@ class Project extends Base
{
public function getProjectById($project_id)
{
return $this->project->getById($project_id);
return $this->formatProject($this->project->getById($project_id));
}
public function getProjectByName($name)
{
return $this->project->getByName($name);
return $this->formatProject($this->project->getByName($name));
}
public function getAllProjects()
{
return $this->project->getAll();
return $this->formatProjects($this->project->getAll());
}
public function removeProject($project_id)
@ -82,4 +82,28 @@ class Project extends Base
list($valid,) = $this->project->validateModification($values);
return $valid && $this->project->update($values);
}
private function formatProject($project)
{
if (! empty($project)) {
$project['url'] = array(
'board' => $this->helper->url->base().$this->helper->url->to('board', 'show', array('project_id' => $project['id'])),
'calendar' => $this->helper->url->base().$this->helper->url->to('calendar', 'show', array('project_id' => $project['id'])),
'list' => $this->helper->url->base().$this->helper->url->to('listing', 'show', array('project_id' => $project['id'])),
);
}
return $project;
}
private function formatProjects($projects)
{
if (! empty($projects)) {
foreach ($projects as &$project) {
$project = $this->formatProject($project);
}
}
return $projects;
}
}

View File

@ -14,17 +14,17 @@ class Task extends Base
{
public function getTask($task_id)
{
return $this->taskFinder->getById($task_id);
return $this->formatTask($this->taskFinder->getById($task_id));
}
public function getTaskByReference($project_id, $reference)
{
return $this->taskFinder->getByReference($project_id, $reference);
return $this->formatTask($this->taskFinder->getByReference($project_id, $reference));
}
public function getAllTasks($project_id, $status_id = TaskModel::STATUS_OPEN)
{
return $this->taskFinder->getAll($project_id, $status_id);
return $this->formatTasks($this->taskFinder->getAll($project_id, $status_id));
}
public function getOverdueTasks()
@ -115,4 +115,24 @@ class Task extends Base
list($valid) = $this->taskValidator->validateApiModification($values);
return $valid && $this->taskModification->update($values);
}
private function formatTask($task)
{
if (! empty($task)) {
$task['url'] = $this->helper->url->base().$this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']));
}
return $task;
}
private function formatTasks($tasks)
{
if (! empty($tasks)) {
foreach ($tasks as &$task) {
$task = $this->formatTask($task);
}
}
return $tasks;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -26,4 +26,20 @@ class Activity extends Base
'title' => t('%s\'s activity', $project['name'])
)));
}
/**
* Display task activities
*
* @access public
*/
public function task()
{
$task = $this->getTask();
$this->response->html($this->taskLayout('activity/task', array(
'title' => $task['title'],
'task' => $task,
'events' => $this->projectActivity->getTask($task['id']),
)));
}
}

View File

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

View File

@ -310,4 +310,28 @@ class Board extends Base
'recurrence_basedate_list' => $this->task->getRecurrenceBasedateList(),
)));
}
/**
* Enable collapsed mode
*
* @access public
*/
public function collapse()
{
$project_id = $this->request->getIntegerParam('project_id');
$this->userSession->setBoardDisplayMode($project_id, true);
$this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $project_id)));
}
/**
* Enable expanded mode
*
* @access public
*/
public function expand()
{
$project_id = $this->request->getIntegerParam('project_id');
$this->userSession->setBoardDisplayMode($project_id, false);
$this->response->redirect($this->helper->url->to('board', 'show', array('project_id' => $project_id)));
}
}

View File

@ -70,7 +70,7 @@ class Export extends Base
*/
public function summary()
{
$this->common('projectDailySummary', 'getAggregatedMetrics', t('Summary'), 'summary', t('Daily project summary export'));
$this->common('ProjectDailyColumnStats', 'getAggregatedMetrics', t('Summary'), 'summary', t('Daily project summary export'));
}
/**

View File

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

View File

@ -64,7 +64,7 @@ class Task extends Base
'time_spent' => $task['time_spent'] ?: '',
);
$this->dateParser->format($values, array('date_started'));
$this->dateParser->format($values, array('date_started'), 'Y-m-d H:i');
$this->response->html($this->taskLayout('task/show', array(
'project' => $this->project->getById($task['project_id']),
@ -88,19 +88,20 @@ class Task extends Base
}
/**
* Display task activities
* Display task analytics
*
* @access public
*/
public function activites()
public function analytics()
{
$task = $this->getTask();
$this->response->html($this->taskLayout('task/activity', array(
$this->response->html($this->taskLayout('task/analytics', array(
'title' => $task['title'],
'task' => $task,
'ajax' => $this->request->isAjax(),
'events' => $this->projectActivity->getTask($task['id']),
'lead_time' => $this->taskAnalytic->getLeadTime($task),
'cycle_time' => $this->taskAnalytic->getCycleTime($task),
'time_spent_columns' => $this->taskAnalytic->getTimeSpentByColumn($task),
)));
}
@ -151,7 +152,6 @@ class Task extends Base
{
$project = $this->getProject();
$values = $this->request->getValues();
$values['creator_id'] = $this->userSession->getId();
list($valid, $errors) = $this->taskValidator->validateCreation($values);
@ -618,4 +618,16 @@ class Task extends Base
'transitions' => $this->transition->getAllByTask($task['id']),
)));
}
/**
* Set automatically the start date
*
* @access public
*/
public function start()
{
$task = $this->getTask();
$this->taskModification->update(array('id' => $task['id'], 'date_started' => time()));
$this->response->redirect($this->helper->url->to('task', 'show', array('project_id' => $task['project_id'], 'task_id' => $task['id'])));
}
}

View File

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

View File

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

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;
use DateTime;
/**
* DateTime helpers
*
* @package helper
* @author Frederic Guillot
*/
class Datetime extends \Core\Base
class Dt extends \Core\Base
{
/**
* Get duration in seconds into human format
*
* @access public
* @param integer $seconds
* @return string
*/
public function duration($seconds)
{
if ($seconds == 0) {
return 0;
}
$dtF = new DateTime("@0");
$dtT = new DateTime("@$seconds");
return $dtF->diff($dtT)->format('%a days, %h hours, %i minutes and %s seconds');
}
/**
* Get the age of an item in quasi human readable format.
* It's in this format: <1h , NNh, NNd

View File

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

View File

@ -39,6 +39,25 @@ class SlackWebhook extends \Core\Base
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
*
@ -69,6 +88,11 @@ class SlackWebhook extends \Core\Base
$payload['text'] .= '|'.t('view the task on Kanboard').'>';
}
$channel = $this->getChannel($project_id);
if (! empty($channel)) {
$payload['channel'] = $channel;
}
$this->httpClient->postJson($this->getWebhookUrl($project_id), $payload);
}
}

View File

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

View File

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

View File

@ -85,8 +85,7 @@ class DateParser extends Base
*/
public function getTimestamp($value)
{
foreach ($this->getDateFormats() as $format) {
foreach ($this->getAllFormats() as $format) {
$timestamp = $this->getValidDate($value, $format);
if ($timestamp !== 0) {
@ -97,6 +96,25 @@ class DateParser extends Base
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)
*
@ -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)
*
@ -143,7 +176,7 @@ class DateParser extends Base
* Get a timetstamp from an ISO date format
*
* @access public
* @param string $date Date format
* @param string $date
* @return integer
*/
public function getTimestampFromIsoFormat($date)
@ -166,7 +199,6 @@ class DateParser extends Base
}
foreach ($fields as $field) {
if (! empty($values[$field])) {
$values[$field] = date($format, $values[$field]);
}
@ -180,15 +212,16 @@ class DateParser extends Base
* Convert date (form input data)
*
* @access public
* @param array $values Database values
* @param string[] $fields Date fields
* @param array $values Database values
* @param string[] $fields Date fields
* @param boolean $keep_time Keep time or not
*/
public function convert(array &$values, array $fields)
public function convert(array &$values, array $fields, $keep_time = false)
{
foreach ($fields as $field) {
if (! empty($values[$field]) && ! is_numeric($values[$field])) {
$values[$field] = $this->removeTimeFromTimestamp($this->getTimestamp($values[$field]));
$timestamp = $this->getTimestamp($values[$field]);
$values[$field] = $keep_time ? $timestamp : $this->removeTimeFromTimestamp($timestamp);
}
}
}

View File

@ -49,7 +49,7 @@ class ProjectAnalytic extends Base
* Get users repartition
*
* @access public
* @param integer $project_id Project id
* @param integer $project_id
* @return array
*/
public function getUserRepartition($project_id)
@ -87,4 +87,96 @@ class ProjectAnalytic extends Base
return array_values($metrics);
}
/**
* Get the average lead and cycle time
*
* @access public
* @param integer $project_id
* @return array
*/
public function getAverageLeadAndCycleTime($project_id)
{
$stats = array(
'count' => 0,
'total_lead_time' => 0,
'total_cycle_time' => 0,
'avg_lead_time' => 0,
'avg_cycle_time' => 0,
);
$tasks = $this->db
->table(Task::TABLE)
->columns('date_completed', 'date_creation', 'date_started')
->eq('project_id', $project_id)
->desc('id')
->limit(1000)
->findAll();
foreach ($tasks as &$task) {
$stats['count']++;
$stats['total_lead_time'] += ($task['date_completed'] ?: time()) - $task['date_creation'];
$stats['total_cycle_time'] += empty($task['date_started']) ? 0 : ($task['date_completed'] ?: time()) - $task['date_started'];
}
$stats['avg_lead_time'] = (int) ($stats['total_lead_time'] / $stats['count']);
$stats['avg_cycle_time'] = (int) ($stats['total_cycle_time'] / $stats['count']);
return $stats;
}
/**
* Get the average time spent into each column
*
* @access public
* @param integer $project_id
* @return array
*/
public function getAverageTimeSpentByColumn($project_id)
{
$stats = array();
$columns = $this->board->getColumnsList($project_id);
// Get the time spent of the last move for each tasks
$tasks = $this->db
->table(Task::TABLE)
->columns('id', 'date_completed', 'date_moved', 'column_id')
->eq('project_id', $project_id)
->desc('id')
->limit(1000)
->findAll();
// Init values
foreach ($columns as $column_id => $column_title) {
$stats[$column_id] = array(
'count' => 0,
'time_spent' => 0,
'average' => 0,
'title' => $column_title,
);
}
// Get time spent foreach task/column and take into account the last move
foreach ($tasks as &$task) {
$sums = $this->transition->getTimeSpentByTask($task['id']);
if (! isset($sums[$task['column_id']])) {
$sums[$task['column_id']] = 0;
}
$sums[$task['column_id']] += ($task['date_completed'] ?: time()) - $task['date_moved'];
foreach ($sums as $column_id => $time_spent) {
$stats[$column_id]['count']++;
$stats[$column_id]['time_spent'] += $time_spent;
}
}
// Calculate average for each column
foreach ($columns as $column_id => $column_title) {
$stats[$column_id]['average'] = (int) ($stats[$column_id]['time_spent'] / $stats[$column_id]['count']);
}
return $stats;
}
}

View File

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

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)
{
$this->dateParser->convert($values, array('date_due', 'date_started'));
$this->dateParser->convert($values, array('date_due'));
$this->dateParser->convert($values, array('date_started'), true);
$this->removeFields($values, array('another_task'));
$this->resetFields($values, array('owner_id', 'swimlane_id', 'date_due', 'score', 'category_id', 'time_estimated'));
$this->resetFields($values, array('creator_id', 'owner_id', 'swimlane_id', 'date_due', 'score', 'category_id', 'time_estimated'));
if (empty($values['column_id'])) {
$values['column_id'] = $this->board->getFirstColumn($values['project_id']);
@ -59,6 +60,10 @@ class TaskCreation extends Base
$values['title'] = t('Untitled');
}
if ($this->userSession->isLogged()) {
$values['creator_id'] = $this->userSession->getId();
}
$values['swimlane_id'] = empty($values['swimlane_id']) ? 0 : $values['swimlane_id'];
$values['date_creation'] = time();
$values['date_modification'] = $values['date_creation'];

View File

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

View File

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

View File

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

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)
{
$original_task = $this->taskFinder->getById($task_id);
if ($position < 1) {
return false;
}
$task = $this->taskFinder->getById($task_id);
// Ignore closed tasks
if ($original_task['is_active'] == Task::STATUS_CLOSED) {
if ($task['is_active'] == Task::STATUS_CLOSED) {
return true;
}
$result = $this->calculateAndSave($project_id, $task_id, $column_id, $position, $swimlane_id);
$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) {
$this->calculateAndSave($project_id, 0, $column_id, 1, $original_task['swimlane_id']);
}
if ($fire_events) {
$this->fireEvents($original_task, $column_id, $position, $swimlane_id);
}
if ($result && $fire_events) {
$this->fireEvents($task, $column_id, $position, $swimlane_id);
}
return $result;
}
/**
* Calculate the new position of all tasks
* Move a task to another swimlane
*
* @access public
* @param integer $project_id Project id
* @param integer $task_id Task id
* @param integer $column_id Column id
* @param integer $position Position (must be >= 1)
* @param integer $swimlane_id Swimlane id
* @return array|boolean
* @access private
* @param integer $project_id
* @param integer $task_id
* @param integer $position
* @param integer $original_column_id
* @param integer $new_column_id
* @param integer $original_swimlane_id
* @param integer $new_swimlane_id
* @return boolean
*/
public function calculatePositions($project_id, $task_id, $column_id, $position, $swimlane_id = 0)
private function saveSwimlaneChange($project_id, $task_id, $position, $original_column_id, $new_column_id, $original_swimlane_id, $new_swimlane_id)
{
// The position can't be lower than 1
if ($position < 1) {
return false;
}
$this->db->startTransaction();
$r1 = $this->saveTaskPositions($project_id, $task_id, 0, $original_column_id, $original_swimlane_id);
$r2 = $this->saveTaskPositions($project_id, $task_id, $position, $new_column_id, $new_swimlane_id);
$this->db->closeTransaction();
$board = $this->db->table(Board::TABLE)->eq('project_id', $project_id)->asc('position')->findAllByColumn('id');
$columns = array();
// For each column fetch all tasks ordered by position
foreach ($board as $board_column_id) {
$columns[$board_column_id] = $this->db->table(Task::TABLE)
->eq('is_active', 1)
->eq('swimlane_id', $swimlane_id)
->eq('project_id', $project_id)
->eq('column_id', $board_column_id)
->neq('id', $task_id)
->asc('position')
->asc('id') // Fix Postgresql unit test
->findAllByColumn('id');
}
// 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;
return $r1 && $r2;
}
/**
* Save task positions
* Move a task to another column
*
* @access private
* @param array $columns Sorted tasks
* @param integer $swimlane_id Swimlane id
* @param integer $project_id
* @param integer $task_id
* @param integer $position
* @param integer $swimlane_id
* @param integer $original_column_id
* @param integer $new_column_id
* @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,
'column_id' => $column_id,
'swimlane_id' => $swimlane_id,
));
/**
* Save all task positions for one 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 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) {
return false;
}
$offset = 1;
$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));
}
}
/**
* 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\MaxLength('title', t('The maximum length is %d characters', 200), 200),
new Validators\Date('date_due', t('Invalid date'), $this->dateParser->getDateFormats()),
new Validators\Date('date_started', t('Invalid date'), $this->dateParser->getDateFormats()),
new Validators\Date('date_started', t('Invalid date'), $this->dateParser->getAllFormats()),
new Validators\Numeric('time_spent', t('This value must be numeric')),
new Validators\Numeric('time_estimated', t('This value must be numeric')),
);

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
*

View File

@ -118,4 +118,28 @@ class UserSession extends Base
{
$_SESSION['filters'][$project_id] = $filters;
}
/**
* Is board collapsed or expanded
*
* @access public
* @param integer $project_id
* @return boolean
*/
public function isBoardCollapsed($project_id)
{
return ! empty($_SESSION['board_collapsed'][$project_id]) ? $_SESSION['board_collapsed'][$project_id] : false;
}
/**
* Set board display mode
*
* @access public
* @param integer $project_id
* @param boolean $collapsed
*/
public function setBoardDisplayMode($project_id, $collapsed)
{
$_SESSION['board_collapsed'][$project_id] = $collapsed;
}
}

View File

@ -6,7 +6,32 @@ use PDO;
use Core\Security;
use Model\Link;
const VERSION = 77;
const VERSION = 79;
function version_79($pdo)
{
$pdo->exec("
CREATE TABLE project_daily_stats (
id INT NOT NULL AUTO_INCREMENT,
day CHAR(10) NOT NULL,
project_id INT NOT NULL,
avg_lead_time INT NOT NULL DEFAULT 0,
avg_cycle_time INT NOT NULL DEFAULT 0,
PRIMARY KEY(id),
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
) ENGINE=InnoDB CHARSET=utf8
");
$pdo->exec('CREATE UNIQUE INDEX project_daily_stats_idx ON project_daily_stats(day, project_id)');
$pdo->exec('RENAME TABLE project_daily_summaries TO project_daily_column_stats');
}
function version_78($pdo)
{
$pdo->exec("ALTER TABLE project_integrations ADD COLUMN slack_webhook_channel VARCHAR(255) DEFAULT ''");
$pdo->exec("INSERT INTO settings VALUES ('integration_slack_webhook_channel', '')");
}
function version_77($pdo)
{

View File

@ -6,7 +6,31 @@ use PDO;
use Core\Security;
use Model\Link;
const VERSION = 57;
const VERSION = 59;
function version_59($pdo)
{
$pdo->exec("
CREATE TABLE project_daily_stats (
id SERIAL PRIMARY KEY,
day CHAR(10) NOT NULL,
project_id INTEGER NOT NULL,
avg_lead_time INTEGER NOT NULL DEFAULT 0,
avg_cycle_time INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
)
");
$pdo->exec('CREATE UNIQUE INDEX project_daily_stats_idx ON project_daily_stats(day, project_id)');
$pdo->exec('ALTER TABLE project_daily_summaries RENAME TO project_daily_column_stats');
}
function version_58($pdo)
{
$pdo->exec("ALTER TABLE project_integrations ADD COLUMN slack_webhook_channel VARCHAR(255) DEFAULT ''");
$pdo->exec("INSERT INTO settings VALUES ('integration_slack_webhook_channel', '')");
}
function version_57($pdo)
{

View File

@ -6,7 +6,31 @@ use Core\Security;
use PDO;
use Model\Link;
const VERSION = 73;
const VERSION = 75;
function version_75($pdo)
{
$pdo->exec("
CREATE TABLE project_daily_stats (
id INTEGER PRIMARY KEY,
day TEXT NOT NULL,
project_id INTEGER NOT NULL,
avg_lead_time INTEGER NOT NULL DEFAULT 0,
avg_cycle_time INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
)
");
$pdo->exec('CREATE UNIQUE INDEX project_daily_stats_idx ON project_daily_stats(day, project_id)');
$pdo->exec('ALTER TABLE project_daily_summaries RENAME TO project_daily_column_stats');
}
function version_74($pdo)
{
$pdo->exec("ALTER TABLE project_integrations ADD COLUMN slack_webhook_channel TEXT DEFAULT ''");
$pdo->exec("INSERT INTO settings VALUES ('integration_slack_webhook_channel', '')");
}
function version_73($pdo)
{

View File

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

View File

@ -22,7 +22,8 @@ class ProjectDailySummarySubscriber extends \Core\Base implements EventSubscribe
public function execute(TaskEvent $event)
{
if (isset($event['project_id'])) {
$this->projectDailySummary->updateTotals($event['project_id'], date('Y-m-d'));
$this->projectDailyColumnStats->updateTotals($event['project_id'], date('Y-m-d'));
$this->projectDailyStats->updateTotals($event['project_id'], date('Y-m-d'));
}
}
}

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>
<?= $this->url->link(t('Burndown chart'), 'analytic', 'burndown', array('project_id' => $project['id'])) ?>
</li>
<li>
<?= $this->url->link(t('Average time into each column'), 'analytic', 'averageTimeByColumn', array('project_id' => $project['id'])) ?>
</li>
<li>
<?= $this->url->link(t('Lead and cycle time'), 'analytic', 'leadAndCycleTime', array('project_id' => $project['id'])) ?>
</li>
</ul>
</div>

View File

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

View File

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

View File

@ -48,7 +48,7 @@
<ul>
<?php if (isset($board_selector) && ! empty($board_selector)): ?>
<li>
<select id="board-selector" tabindex=="-1" data-notfound="<?= t('No results match:') ?>" data-placeholder="<?= t('Display another project') ?>" data-board-url="<?= $this->url->href('board', 'show', array('project_id' => 'PROJECT_ID')) ?>">
<select id="board-selector" tabindex="-1" data-notfound="<?= t('No results match:') ?>" data-placeholder="<?= t('Display another project') ?>" data-board-url="<?= $this->url->href('board', 'show', array('project_id' => 'PROJECT_ID')) ?>">
<option value=""></option>
<?php foreach($board_selector as $board_id => $board_name): ?>
<option value="<?= $board_id ?>"><?= $this->e($board_name) ?></option>

View File

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

View File

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

View File

@ -48,7 +48,7 @@
<?php if ($subtask['is_timer_started']): ?>
<i class="fa fa-pause"></i>
<?= $this->url->link(t('Stop timer'), 'timer', 'subtask', array('timer' => 'stop', 'project_id' => $task['project_id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'])) ?>
(<?= $this->datetime->age($subtask['timer_start_date']) ?>)
(<?= $this->dt->age($subtask['timer_start_date']) ?>)
<?php else: ?>
<i class="fa fa-play-circle-o"></i>
<?= $this->url->link(t('Start timer'), 'timer', 'subtask', array('timer' => 'start', 'project_id' => $task['project_id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'])) ?>

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>
<li>
<i class="fa fa-th fa-fw"></i>
<?= $this->url->link(t('Back to the board'), 'board', 'show', array('project_id' => $task['project_id']), false, '', '', false, 'swimlane-'.$task['swimlane_id']) ?>
<?= $this->url->link(t('Back to the board'), 'board', 'show', array('project_id' => $task['project_id']), false, '', '', false, $task['swimlane_id'] != 0 ? 'swimlane-'.$task['swimlane_id'] : '') ?>
</li>
<li>
<i class="fa fa-calendar fa-fw"></i>

View File

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

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">
<?php if (empty($values['date_started'])): ?>
<?= $this->url->link('<i class="fa fa-play"></i>', 'task', 'start', array('task_id' => $task['id'], 'project_id' => $task['project_id']), false, 'task-show-start-link', t('Set automatically the start date')) ?>
<?php endif ?>
<?= $this->form->csrf() ?>
<?= $this->form->hidden('id', $values) ?>
<?= $this->form->label(t('Start date'), 'date_started') ?>
<?= $this->form->text('date_started', $values, array(), array('placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?>
<?= $this->form->text('date_started', $values, array(), array('placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-datetime') ?>
<?= $this->form->label(t('Time estimated'), 'time_estimated') ?>
<?= $this->form->numeric('time_estimated', $values, array(), array('placeholder="'.t('hours').'"')) ?>

View File

@ -19,7 +19,7 @@
<td><?= $this->e($transition['src_column']) ?></td>
<td><?= $this->e($transition['dst_column']) ?></td>
<td><?= $this->url->link($this->e($transition['name'] ?: $transition['username']), 'user', 'show', array('user_id' => $transition['user_id'])) ?></td>
<td><?= n(round($transition['time_spent'] / 3600, 2)).' '.t('hours') ?></td>
<td><?= $this->dt->duration($transition['time_spent']) ?></td>
</tr>
<?php endforeach ?>
</table>

View File

@ -30,10 +30,10 @@
<?= $this->form->csrf() ?>
<?= $this->form->label(t('Start time'), 'start') ?>
<?= $this->form->select('start', $this->datetime->getDayHours(), $values, $errors) ?>
<?= $this->form->select('start', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('End time'), 'end') ?>
<?= $this->form->select('end', $this->datetime->getDayHours(), $values, $errors) ?>
<?= $this->form->select('end', $this->dt->getDayHours(), $values, $errors) ?>
<div class="form-actions">
<input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>

View File

@ -42,10 +42,10 @@
<?= $this->form->checkbox('all_day', t('All day'), 1) ?>
<?= $this->form->label(t('Start time'), 'start') ?>
<?= $this->form->select('start', $this->datetime->getDayHours(), $values, $errors) ?>
<?= $this->form->select('start', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('End time'), 'end') ?>
<?= $this->form->select('end', $this->datetime->getDayHours(), $values, $errors) ?>
<?= $this->form->select('end', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('Comment'), 'comment') ?>
<?= $this->form->text('comment', $values, $errors) ?>

View File

@ -42,10 +42,10 @@
<?= $this->form->checkbox('all_day', t('All day'), 1) ?>
<?= $this->form->label(t('Start time'), 'start') ?>
<?= $this->form->select('start', $this->datetime->getDayHours(), $values, $errors) ?>
<?= $this->form->select('start', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('End time'), 'end') ?>
<?= $this->form->select('end', $this->datetime->getDayHours(), $values, $errors) ?>
<?= $this->form->select('end', $this->dt->getDayHours(), $values, $errors) ?>
<?= $this->form->label(t('Comment'), 'comment') ?>
<?= $this->form->text('comment', $values, $errors) ?>

View File

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

View File

@ -1,6 +1,6 @@
<?php
require 'vendor/autoload.php';
require dirname(__DIR__) . '/vendor/autoload.php';
// Automatically parse environment configuration (Heroku)
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;
margin-right: 10px;
}
/* datepicker */
#ui-datepicker-div {
font-size: 0.8em;
}

View File

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

View File

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

View File

@ -1,10 +1,10 @@
/* task inside the board */
.task-board {
position: relative;
margin-bottom: 5px;
margin-bottom: 2px;
border: 1px solid #000;
padding: 3px;
font-size: 0.9em;
padding: 2px;
font-size: 0.85em;
word-wrap: break-word;
}
@ -44,11 +44,12 @@ a.task-board-collapsed-title {
.task-board .dropdown {
float: left;
margin-right: 5px;
font-size: 1.1em;
}
.task-board-title {
margin-top: 10px;
margin-bottom: 10px;
margin-top: 5px;
margin-bottom: 5px;
font-size: 1.1em;
}
@ -77,8 +78,6 @@ a.task-board-nobody {
.task-board-category-container {
text-align: right;
padding-bottom: 2px;
margin-top: 10px;
}
.task-board-category {
@ -289,3 +288,12 @@ span.task-board-date-overdue {
.task-show-file-table {
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 columns = [];
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++) {
@ -19,7 +22,12 @@
}
}
else {
columns[j].push(metrics[i][j]);
if (j == 0) {
categories.push(outputFormat(inputFormat.parse(metrics[i][j])));
}
}
}
}
@ -27,16 +35,13 @@
c3.generate({
data: {
columns: columns,
x: metrics[0][0],
type: 'area-spline',
groups: [groups]
},
axis: {
x: {
type: 'timeseries',
tick: {
format: $("#chart").data("date-format")
}
type: 'category',
categories: categories
}
}
});
@ -47,6 +52,9 @@
{
var metrics = $("#chart").data("metrics");
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++) {
@ -66,21 +74,22 @@
columns[0][i] += metrics[i][j];
}
if (j == 0) {
categories.push(outputFormat(inputFormat.parse(metrics[i][j])));
}
}
}
}
c3.generate({
data: {
columns: columns,
x: metrics[0][0]
columns: columns
},
axis: {
x: {
type: 'timeseries',
tick: {
format: $("#chart").data("date-format")
}
type: 'category',
categories: categories
}
}
});
@ -125,11 +134,13 @@
// Draw budget chart
function drawBudget()
{
var categories = [];
var metrics = $("#chart").data("metrics");
var labels = $("#chart").data("labels");
var inputFormat = d3.time.format("%Y-%m-%d");
var outputFormat = d3.time.format($("#chart").data("date-format"));
var columns = [
[labels["date"]],
[labels["in"]],
[labels["left"]],
[labels["out"]]
@ -141,30 +152,178 @@
colors[labels["out"]] = '#DF3A01';
for (var i = 0; i < metrics.length; i++) {
columns[0].push(metrics[i]["date"]);
columns[1].push(metrics[i]["in"]);
columns[2].push(metrics[i]["left"]);
columns[3].push(metrics[i]["out"]);
categories.push(outputFormat(inputFormat.parse(metrics[i]["date"])));
columns[0].push(metrics[i]["in"]);
columns[1].push(metrics[i]["left"]);
columns[2].push(metrics[i]["out"]);
}
c3.generate({
data: {
x: columns[0][0],
columns: columns,
colors: colors,
type : 'bar'
},
bar: {
width: {
ratio: 0.25
}
},
grid: {
x: {
show: true
},
y: {
show: true
}
},
axis: {
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: {
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() {
if (Kanboard.Exists("analytic-task-repartition")) {
@ -182,6 +341,15 @@
else if (Kanboard.Exists("budget-chart")) {
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
});
// 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").click(Kanboard.MarkdownPreview);
$("#markdown-write").click(Kanboard.MarkdownWriter);

View File

@ -20,7 +20,7 @@
});
Mousetrap.bind("s", function() {
stack_toggle();
window.location = $(".board-display-mode").attr("href");
});
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
function board_load_events()
{
@ -243,7 +175,6 @@
$("#main").append(data);
Kanboard.InitAfterAjax();
board_load_events();
stack_show();
compactview_reload();
}
});
@ -263,7 +194,6 @@
Kanboard.InitAfterAjax();
board_unload_events();
board_load_events();
stack_show();
compactview_reload();
}
}
@ -312,7 +242,6 @@
if (Kanboard.Exists("board")) {
board_load_events();
stack_load_events();
compactview_load_events();
keyboard_shortcuts();
}

File diff suppressed because one or more lines are too long

View File

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

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",
"This file is @generated automatically"
],
"hash": "1ef3f084b6c8651977b1bbc84d86cb69",
"hash": "0048471872ea99cd30c53c0398c7d9f2",
"packages": [
{
"name": "christian-riesen/base32",
@ -260,16 +260,16 @@
},
{
"name": "fguillot/json-rpc",
"version": "dev-master",
"version": "v1.0.0",
"source": {
"type": "git",
"url": "https://github.com/fguillot/JsonRPC.git",
"reference": "ac3ddcf8f74777d72b8044d6d128f41aabe292f2"
"reference": "5a11f1414780a200f09b78d20ab72b5cee4faa95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/fguillot/JsonRPC/zipball/ac3ddcf8f74777d72b8044d6d128f41aabe292f2",
"reference": "ac3ddcf8f74777d72b8044d6d128f41aabe292f2",
"url": "https://api.github.com/repos/fguillot/JsonRPC/zipball/5a11f1414780a200f09b78d20ab72b5cee4faa95",
"reference": "5a11f1414780a200f09b78d20ab72b5cee4faa95",
"shasum": ""
},
"require": {
@ -292,7 +292,7 @@
],
"description": "Simple Json-RPC client/server library that just works",
"homepage": "https://github.com/fguillot/JsonRPC",
"time": "2015-07-01 19:26:37"
"time": "2015-07-01 19:50:31"
},
{
"name": "fguillot/picodb",
@ -819,7 +819,6 @@
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {
"fguillot/json-rpc": 20,
"swiftmailer/swiftmailer": 0,
"symfony/console": 0
},

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
=========
Each project have an analytics section. Depending how you are using Kanboard, you can see those reports:
User repartition
----------------
@ -20,7 +22,8 @@ Cumulative flow diagram
![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.
@ -30,13 +33,37 @@ Burndown chart
![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.
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
-------------------------------------------------------
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",
"is_active": "1",
"token": "",
"last_modified": "1410263246",
"last_modified": "1436119135",
"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",
"is_active": "1",
"token": "",
"last_modified": "0",
"last_modified": "1436119135",
"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",
"method": "getAllProjects",
"id": 134982303
"id": 2134420212
}
```
@ -375,25 +395,26 @@ Response example:
```json
{
"jsonrpc": "2.0",
"id": 134982303,
"id": 2134420212,
"result": [
{
"id": "2",
"name": "PHP client",
"is_active": "1",
"token": "",
"last_modified": "0",
"is_public": "0",
"description": "PHP client project"
},
{
"id": "1",
"name": "Test",
"name": "API test",
"is_active": "1",
"token": "",
"last_modified": "0",
"last_modified": "1436119570",
"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_basedate": "0",
"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_basedate": "0",
"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_basedate": "0",
"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",
@ -2128,7 +2152,8 @@ Response example:
"recurrence_timeframe": "0",
"recurrence_basedate": "0",
"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`
```bash
$ ./kanboard
Kanboard version master
Usage:
command [options] [arguments]
command [options] [arguments]
Options:
--help (-h) Display this help message
--quiet (-q) Do not output any message
--verbose (-v|vv|vvv) Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
--version (-V) Display this application version
--ansi Force ANSI output
--no-ansi Disable ANSI output
--no-interaction (-n) Do not ask any interactive question
-h, --help Display this help message
-q, --quiet Do not output any message
-V, --version Display this application version
--ansi Force ANSI output
--no-ansi Disable ANSI output
-n, --no-interaction Do not ask any interactive question
-v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug
Available commands:
help Displays help for a command
list Lists commands
export
export:daily-project-summary Daily project summary CSV export (number of tasks per column and per day)
export:subtasks Subtasks CSV export
export:tasks Tasks CSV export
export:transitions Task transitions CSV export
locale
locale:compare Compare application translations with the fr_FR locale
locale:sync Synchronize all translations based on the fr_FR locale
notification
notification:overdue-tasks Send notifications for overdue tasks
projects
projects:daily-summary Calculate daily summary data for all projects
help Displays help for a command
list Lists commands
export
export:daily-project-column-stats Daily project column stats CSV export (number of tasks per column and per day)
export:subtasks Subtasks CSV export
export:tasks Tasks CSV export
export:transitions Task transitions CSV export
locale
locale:compare Compare application translations with the fr_FR locale
locale:sync Synchronize all translations based on the fr_FR locale
notification
notification:overdue-tasks Send notifications for overdue tasks
projects
projects:daily-stats Calculate daily statistics for all projects
```
Available commands
@ -97,13 +96,13 @@ Example:
The exported data will be printed on the standard output:
```bash
./kanboard export:daily-project-summary <project_id> <start_date> <end_date>
./kanboard export:daily-project-column-stats <project_id> <start_date> <end_date>
```
Example:
```bash
./kanboard export:daily-project-summary 1 2014-10-01 2014-11-30 > /tmp/my_custom_export.csv
./kanboard export:daily-project-column-stats 1 2014-10-01 2014-11-30 > /tmp/my_custom_export.csv
```
### Send notifications for overdue tasks
@ -133,12 +132,12 @@ Cronjob example:
0 8 * * * cd /path/to/kanboard && ./kanboard notification:overdue-tasks >/dev/null 2>&1
```
### Run daily project summaries calculation
### Run daily project stats calculation
You can add a background task that calculate the daily project summaries everyday:
You can add a background task to calculate the project statistics everyday:
```bash
$ ./kanboard projects:daily-summary
$ ./kanboard projects:daily-stats
Run calculation for Project #0
Run calculation for Project #1
Run calculation for Project #10

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.
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
-----------------

View File

@ -33,6 +33,7 @@ Using Kanboard
- [Recurring tasks](recurring-tasks.markdown)
- [Create tasks by email](create-tasks-by-email.markdown)
- [Subtasks](subtasks.markdown)
- [Analytics for tasks](analytics-tasks.markdown)
### 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**
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\SubtaskExport($container));
$application->add(new Console\TaskExport($container));
$application->add(new Console\ProjectDailySummaryCalculation($container));
$application->add(new Console\ProjectDailySummaryExport($container));
$application->add(new Console\ProjectDailyStatsCalculation($container));
$application->add(new Console\ProjectDailyColumnStatsExport($container));
$application->add(new Console\TransitionExport($container));
$application->add(new Console\LocaleSync($container));
$application->add(new Console\LocaleComparator($container));

View File

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

View File

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

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"
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"
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"
function merge_css {

View File

@ -63,6 +63,9 @@ class Api extends PHPUnit_Framework_TestCase
if ($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']);
$this->assertTrue($this->client->removeProject($project['id']));
}
}
@ -80,6 +83,9 @@ class Api extends PHPUnit_Framework_TestCase
$project = $this->client->getProjectById(1);
$this->assertNotEmpty($project);
$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()
@ -87,6 +93,9 @@ class Api extends PHPUnit_Framework_TestCase
$project = $this->client->getProjectByName('API test');
$this->assertNotEmpty($project);
$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'));
$this->assertNotEmpty($project);
@ -97,6 +106,18 @@ class Api extends PHPUnit_Framework_TestCase
$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()
{
$project = $this->client->getProjectById(1);
@ -385,6 +406,7 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertNotFalse($task);
$this->assertTrue(is_array($task));
$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()
@ -394,6 +416,7 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertNotFalse($tasks);
$this->assertTrue(is_array($tasks));
$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);
@ -1030,5 +1053,6 @@ class Api extends PHPUnit_Framework_TestCase
$this->assertNotEmpty($task);
$this->assertEquals('Task with external ticket number', $task['title']);
$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()
{
date_default_timezone_set('UTC');
if (DB_DRIVER === 'mysql') {
$pdo = new PDO('mysql:host='.DB_HOSTNAME, DB_USERNAME, DB_PASSWORD);
$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('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()

View File

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

View File

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

View File

@ -175,6 +175,28 @@ class TaskCreationTest extends Base
$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()
{
$p = new Project($this->container);
@ -284,29 +306,35 @@ class TaskCreationTest extends Base
public function testDateStarted()
{
$date = '2014-11-23';
$timestamp = strtotime('+2days');
$p = new Project($this->container);
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$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);
$this->assertNotEmpty($task);
$this->assertNotFalse($task);
$this->assertEquals('2014-11-24 '.date('H:i'), date('Y-m-d H:i', $task['date_started']));
$this->assertEquals(1, $task['id']);
$this->assertEquals($date, date('Y-m-d', $task['date_started']));
// Set a datetime
$this->assertEquals(2, $tc->create(array('project_id' => 1, 'title' => 'test', 'date_started' => '2014-11-24 16:25')));
$task = $tf->getById(2);
$this->assertNotEmpty($task);
$this->assertNotFalse($task);
$this->assertEquals('2014-11-24 16:25', date('Y-m-d H:i', $task['date_started']));
$this->assertEquals(2, $task['id']);
$this->assertEquals($timestamp, $task['date_started']);
// Set a datetime
$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()

View File

@ -15,6 +15,36 @@ use Model\Swimlane;
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()
{
$td = new TaskDuplication($this->container);

View File

@ -9,9 +9,60 @@ use Model\TaskCreation;
use Model\DateParser;
use Model\Category;
use Model\Subtask;
use Model\Config;
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()
{
$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 amazing', $tasks[1]['title']);
$tf->search('assignee:nobody');
$tasks = $tf->findAll();
$this->assertNotEmpty($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()
{
$tf = new TaskFilter($this->container);

View File

@ -202,15 +202,29 @@ class TaskModificationTest extends Base
$task = $tf->getById(1);
$this->assertEquals(0, $task['date_started']);
// Set only a date
$this->assertTrue($tm->update(array('id' => 1, 'date_started' => '2014-11-24')));
$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())));
$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()

View File

@ -11,49 +11,88 @@ use Model\Swimlane;
class TaskPositionTest extends Base
{
public function testCalculatePositionBadPosition()
public function testMoveTaskToWrongPosition()
{
$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, $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);
$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, $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);
$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, $tc->create(array('title' => 'Task #1', 'project_id' => 1)));
$positions = $tp->calculatePositions(1, 1, 2, 1);
$this->assertNotFalse($positions);
$this->assertNotEmpty($positions);
$this->assertEmpty($positions[1]);
$this->assertEmpty($positions[3]);
$this->assertEmpty($positions[4]);
$this->assertEquals(array(1), $positions[2]);
$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 column 3
$this->assertTrue($tp->movePosition(1, 1, 3, 1));
// 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);
$tc = new TaskCreation($this->container);
@ -116,40 +155,6 @@ class TaskPositionTest extends Base
$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()
{
$tp = new TaskPosition($this->container);

View File

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