CmdTimesheet: Rewrote the command

- Updated the 'timesheet' command with a more compact report that accepts a
  filter, and has a default filter showing the last four weeks of completed and
  started tasks.
This commit is contained in:
Paul Beckingham 2017-02-09 08:19:30 -05:00
parent 4c20ff04c2
commit 440cfb009e
10 changed files with 182 additions and 190 deletions

View file

@ -80,6 +80,9 @@
(thanks to Jelle van der Waa). (thanks to Jelle van der Waa).
- Improved portability for SunOS-like OSes. - Improved portability for SunOS-like OSes.
(thanks to Antonio Huete Jimenez). (thanks to Antonio Huete Jimenez).
- Updated the 'timesheet' command with a more compact report that accepts a
filter, and has a default filter showing the last four weeks of completed and
started tasks.
------ current release --------------------------- ------ current release ---------------------------

9
NEWS
View file

@ -4,20 +4,25 @@ New Features in Taskwarrior 2.6.0
- The 'QUARTER' virutal tag was added. - The 'QUARTER' virutal tag was added.
- Improved compatibility with SmartOS, OmniOS and OpenIndiana. - Improved compatibility with SmartOS, OmniOS and OpenIndiana.
- New DOM reference: annotations.count. - New DOM reference: annotations.count.
- Renovated 'timesheet' command with a more compact report that accepts a
filter, and has a default filter showing the last four weeks of completed
and started tasks.
New Commands in Taskwarrior 2.6.0 New Commands in Taskwarrior 2.6.0
- The 'purge' command was introduced. - The 'purge' command was added, which completely removes old tasks.
New Configuration Options in Taskwarrior 2.6.0 New Configuration Options in Taskwarrior 2.6.0
- The 'default.scheduled' date/duraiton works just like 'default.due'. - The 'default.scheduled' date/duraiton works just like 'default.due'.
- The 'report.timesheet.filter' setting controls the tasks shown by the
'timesheet' command.
Newly Deprecated Features in Taskwarrior 2.6.0 Newly Deprecated Features in Taskwarrior 2.6.0
- The 'DUETODAY' virtual tag is a synonym for the 'TODAY' virtual tag, and is - The 'DUETODAY' virtual tag is a synonym for the 'TODAY' virtual tag, and is
not needed. not needed.
- The 'new-uuid' verbosity option is to be removed due to being redundant, its - The 'new-uuid' verbosity option is to be removed, as it is redundant, its
functionality will be merged with 'new-id' option. functionality will be merged with 'new-id' option.
- The use of alternate Boolean configuration settings is deprecated. Use values - The use of alternate Boolean configuration settings is deprecated. Use values
"0" for off, and "1" for on. Avoid used of "on", "off", "true", "t", "0" for off, and "1" for on. Avoid used of "on", "off", "true", "t",

View file

@ -1279,6 +1279,9 @@ will be presented in the order (if any) in which they are selected.
This adds a filter to the report X so that only tasks matching the filter This adds a filter to the report X so that only tasks matching the filter
criteria are displayed in the generated report. criteria are displayed in the generated report.
There is a special case for 'report.timesheet.filter', which may be specified
even though the 'timesheet' report is not very customizable.
.TP .TP
.B report.X.dateformat .B report.X.dateformat
This adds a dateformat to the report X that will be used by the "due date" This adds a dateformat to the report X that will be used by the "due date"

View file

@ -251,6 +251,7 @@ std::string Config::_defaults =
"#default.due=eom # Default due date for 'add' command\n" "#default.due=eom # Default due date for 'add' command\n"
"#default.scheduled=eom # Default scheduled date for 'add' command\n" "#default.scheduled=eom # Default scheduled date for 'add' command\n"
"default.command=next # When no arguments are specified\n" "default.command=next # When no arguments are specified\n"
"default.timesheet.filter=( +PENDING and start.after:now-4wks ) or ( +COMPLETED and end.after:now-4wks )\n"
"\n" "\n"
"_forcecolor=0 # Forces color to be on, even for non TTY output\n" "_forcecolor=0 # Forces color to be on, even for non TTY output\n"
"complete.all.tags=0 # Include old tag names in '_ags' command\n" "complete.all.tags=0 # Include old tag names in '_ags' command\n"
@ -384,6 +385,8 @@ std::string Config::_defaults =
"report.blocking.columns=id,uuid.short,start.active,depends,project,tags,recur,wait,scheduled.remaining,due.relative,until.remaining,description.count,urgency\n" "report.blocking.columns=id,uuid.short,start.active,depends,project,tags,recur,wait,scheduled.remaining,due.relative,until.remaining,description.count,urgency\n"
"report.blocking.sort=urgency-,due+,entry+\n" "report.blocking.sort=urgency-,due+,entry+\n"
"report.blocking.filter=status:pending +BLOCKING\n" "report.blocking.filter=status:pending +BLOCKING\n"
"\n"
"report.timesheet.filter=(+PENDING and start.after:now-4wks) or (+COMPLETED and end.after:now-4wks)\n"
"\n"; "\n";
extern Context context; extern Context context;

View file

@ -103,7 +103,7 @@ int CmdInfo::execute (std::string& output)
view.width (context.getWidth ()); view.width (context.getWidth ());
if (context.config.getBoolean ("obfuscate")) if (context.config.getBoolean ("obfuscate"))
view.obfuscate (); view.obfuscate ();
if (context.config.getBoolean ("color")) if (context.color ())
view.forceColor (); view.forceColor ();
view.add (STRING_COLUMN_LABEL_NAME); view.add (STRING_COLUMN_LABEL_NAME);
view.add (STRING_COLUMN_LABEL_VALUE); view.add (STRING_COLUMN_LABEL_VALUE);
@ -113,11 +113,11 @@ int CmdInfo::execute (std::string& output)
{ {
Color alternate (context.config.get ("color.alternate")); Color alternate (context.config.get ("color.alternate"));
view.colorOdd (alternate); view.colorOdd (alternate);
view.intraColorOdd (alternate);
Color label (context.config.get ("color.label")); view.colorHeader (Color ("underline " + context.config.get ("color.label")));
view.colorHeader (label);
} }
else
view.underlineHeaders ();
Datetime now; Datetime now;
@ -444,8 +444,7 @@ int CmdInfo::execute (std::string& output)
urgencyDetails.colorOdd (alternate); urgencyDetails.colorOdd (alternate);
urgencyDetails.intraColorOdd (alternate); urgencyDetails.intraColorOdd (alternate);
Color label (context.config.get ("color.label")); urgencyDetails.colorHeader (Color ("underline " + context.config.get ("color.label")));
urgencyDetails.colorHeader (label);
} }
if (context.config.getBoolean ("obfuscate")) if (context.config.getBoolean ("obfuscate"))
@ -548,9 +547,10 @@ int CmdInfo::execute (std::string& output)
journal.colorOdd (alternate); journal.colorOdd (alternate);
journal.intraColorOdd (alternate); journal.intraColorOdd (alternate);
Color label (context.config.get ("color.label")); journal.colorHeader (Color ("underline " + context.config.get ("color.label")));
journal.colorHeader (label);
} }
else
journal.underlineHeaders ();
if (context.config.getBoolean ("obfuscate")) if (context.config.getBoolean ("obfuscate"))
journal.obfuscate (); journal.obfuscate ();

View file

@ -147,6 +147,7 @@ int CmdShow::execute (std::string& output)
" default.scheduled" " default.scheduled"
" defaultheight" " defaultheight"
" defaultwidth" " defaultwidth"
" default.timesheet.filter"
" dependency.confirmation" " dependency.confirmation"
" dependency.indicator" " dependency.indicator"
" dependency.reminder" " dependency.reminder"

View file

@ -26,6 +26,7 @@
#include <cmake.h> #include <cmake.h>
#include <CmdTimesheet.h> #include <CmdTimesheet.h>
#include <algorithm>
#include <sstream> #include <sstream>
#include <stdlib.h> #include <stdlib.h>
#include <Context.h> #include <Context.h>
@ -33,6 +34,7 @@
#include <Table.h> #include <Table.h>
#include <Datetime.h> #include <Datetime.h>
#include <main.h> #include <main.h>
#include <util.h>
#include <i18n.h> #include <i18n.h>
#include <format.h> #include <format.h>
@ -42,16 +44,16 @@ extern Context context;
CmdTimesheet::CmdTimesheet () CmdTimesheet::CmdTimesheet ()
{ {
_keyword = "timesheet"; _keyword = "timesheet";
_usage = "task timesheet [weeks]"; _usage = "task [filter] timesheet";
_description = STRING_CMD_TIMESHEET_USAGE; _description = STRING_CMD_TIMESHEET_USAGE;
_read_only = true; _read_only = true;
_displays_id = false; _displays_id = false;
_needs_gc = true; _needs_gc = true;
_uses_context = false; _uses_context = false;
_accepts_filter = false; _accepts_filter = true;
_accepts_modifications = false; _accepts_modifications = false;
_accepts_miscellaneous = true; _accepts_miscellaneous = false;
_category = Command::Category::graphs; _category = Command::Category::report;
} }
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
@ -59,165 +61,145 @@ int CmdTimesheet::execute (std::string& output)
{ {
int rc = 0; int rc = 0;
// Scan the pending tasks. // Detect a filter.
handleRecurrence (); bool hasFilter {false};
std::vector <Task> all = context.tdb2.all_tasks (); for (auto& a : context.cli2._args)
// What day of the week does the user consider the first?
int weekStart = Datetime::dayOfWeek (context.config.get ("weekstart"));
if (weekStart != 0 && weekStart != 1)
throw std::string (STRING_DATE_BAD_WEEKSTART);
// Determine the date of the first day of the most recent report.
Datetime today;
Datetime start;
start -= (((today.dayOfWeek () - weekStart) + 7) % 7) * 86400;
// Roll back to midnight.
start = Datetime (start.year (), start.month (), start.day ());
Datetime end = start + (7 * 86400);
// Determine how many reports to run.
int quantity = 1;
std::vector <std::string> words = context.cli2.getWords ();
if (words.size () == 1)
quantity = strtol (words[0].c_str (), NULL, 10);;
std::stringstream out;
for (int week = 0; week < quantity; ++week)
{ {
Datetime endString (end); if (a.hasTag ("FILTER"))
endString -= 86400;
std::string title = start.toString (context.config.get ("dateformat"))
+ " - "
+ endString.toString (context.config.get ("dateformat"));
Color bold;
if (context.color ())
bold = Color ("bold");
out << '\n'
<< bold.colorize (title)
<< '\n';
// Render the completed table.
Table completed;
completed.width (context.getWidth ());
completed.add (" ");
completed.add (STRING_COLUMN_LABEL_PROJECT);
completed.add (STRING_COLUMN_LABEL_DUE, false);
completed.add (STRING_COLUMN_LABEL_DESC);
Color label;
if (context.color ())
{ {
label = Color (context.config.get ("color.label")); hasFilter = true;
completed.colorHeader (label); break;
}
} }
for (auto& task : all) if (! hasFilter)
{
auto defaultFilter = context.config.get ("report.timesheet.filter");
if (defaultFilter == "")
defaultFilter = "(+PENDING and start.after:now-4wks) or (+COMPLETED and end.after:now-4wks)";
context.cli2.addFilter (defaultFilter);
}
// Apply filter to get a set of tasks.
handleRecurrence ();
Filter filter;
std::vector <Task> filtered;
filter.subset (filtered);
// Subset the tasks to only those that are either completed or started.
// The _key attribute is represents either the 'start' or 'end' date.
int num_completed = 0;
int num_started = 0;
std::vector <Task> shown;
for (auto& task : filtered)
{ {
// If task completed within range.
if (task.getStatus () == Task::completed) if (task.getStatus () == Task::completed)
{ {
Datetime compDate (task.get_date ("end")); task.set ("_key", task.get ("end"));
if (compDate >= start && compDate < end) ++num_completed;
}
if (task.getStatus () == Task::pending && task.has ("start"))
{ {
Color c; task.set ("_key", task.get ("start"));
autoColorize (task, c); ++num_started;
}
int row = completed.addRow (); shown.push_back (task);
std::string format = context.config.get ("dateformat.report"); }
if (format == "")
format = context.config.get ("dateformat");
completed.set (row, 1, task.get ("project"), c);
if(task.has ("due")) // Sort tasks by _key.
std::sort (shown.begin (),
shown.end (),
[](const Task& a, const Task& b) { return a.get ("_key") < b.get ("_key"); });
// Render the completed table.
Table table;
table.width (context.getWidth ());
if (context.config.getBoolean ("obfuscate"))
table.obfuscate ();
table.add ("Wk");
table.add ("Date");
table.add ("Day");
table.add ("Action");
table.add ("Project");
table.add ("Due");
table.add ("Task");
if (context.color ())
{ {
Datetime dt (task.get_date ("due")); table.forceColor ();
completed.set (row, 2, dt.toString (format)); table.colorHeader (Color ("underline " + context.config.get ("color.label")));
table.colorOdd (Color (context.config.get ("color.alternate")));
}
else
table.underlineHeaders ();
auto dateformat = context.config.get ("dateformat");
int previous_week = -1;
std::string previous_date = "";
std::string previous_day = "";
int weekCounter = 0;
Color week_color;
for (auto& task : shown)
{
Datetime key (task.get_date ("_key"));
std::string label = task.has ("end") ? "Completed"
: task.has ("start") ? "Started"
: "";
auto week = key.week ();
auto date = key.toString (dateformat);
auto due = task.has ("due") ? Datetime (task.get ("due")).toString (dateformat) : "";
auto day = Datetime::dayNameShort (key.dayOfWeek ());
Color task_color;
autoColorize (task, task_color);
// Add a blank line between weeks.
if (week != previous_week && previous_week != -1)
{
auto row = table.addRowEven ();
table.set (row, 0, " ");
} }
std::string description = task.get ("description"); // Keep track of unique week numbers.
int indent = context.config.getInteger ("indent.annotation"); if (week != previous_week)
++weekCounter;
for (auto& ann : task.getAnnotations ()) // User-defined oddness.
description += '\n' int row;
+ std::string (indent, ' ') if (weekCounter % 2)
+ Datetime (ann.first.substr (11)).toString (context.config.get ("dateformat")) row = table.addRowOdd ();
+ ' ' else
+ ann.second; row = table.addRowEven ();
completed.set (row, 3, description, c); // If the data doesn't change, it doesn't get shown.
} table.set (row, 0, (week != previous_week ? format ("W{1}", week) : ""));
} table.set (row, 1, (date != previous_date ? date : ""));
table.set (row, 2, (day != previous_day ? day : ""));
table.set (row, 3, label);
table.set (row, 4, task.get ("project"));
table.set (row, 5, due);
table.set (row, 6, task.get ("description"), task_color);
previous_week = week;
previous_date = date;
previous_day = day;
} }
out << " " << format (STRING_CMD_TIMESHEET_DONE, completed.rows ()) << '\n'; // Render the table.
std::stringstream out;
if (completed.rows ()) if (table.rows ())
out << completed.render () out << optionalBlankLine ()
<< table.render ()
<< '\n'; << '\n';
// Now render the started table. if (context.verbose ("affected"))
Table started; out << format ("{1} completed, {2} started.", num_completed, num_started)
started.width (context.getWidth ()); << '\n';
started.add (" ");
started.add (STRING_COLUMN_LABEL_PROJECT);
started.add (STRING_COLUMN_LABEL_DUE, false);
started.add (STRING_COLUMN_LABEL_DESC);
started.colorHeader (label);
for (auto& task : all)
{
// If task started within range, but not completed withing range.
if (task.getStatus () == Task::pending &&
task.has ("start"))
{
Datetime startDate (task.get_date ("start"));
if (startDate >= start && startDate < end)
{
Color c;
autoColorize (task, c);
int row = started.addRow ();
std::string format = context.config.get ("dateformat.report");
if (format == "")
format = context.config.get ("dateformat");
started.set (row, 1, task.get ("project"), c);
if (task.has ("due"))
{
Datetime dt (task.get_date ("due"));
started.set (row, 2, dt.toString (format));
}
std::string description = task.get ("description");
int indent = context.config.getInteger ("indent.annotation");
for (auto& ann : task.getAnnotations ())
description += '\n'
+ std::string (indent, ' ')
+ Datetime (ann.first.substr (11)).toString (context.config.get ("dateformat"))
+ ' '
+ ann.second;
started.set (row, 3, description, c);
}
}
}
out << " " << format (STRING_CMD_TIMESHEET_STARTED, started.rows ()) << '\n';
if (started.rows ())
out << started.render ()
<< "\n\n";
// Prior week.
start -= 7 * 86400;
end -= 7 * 86400;
}
output = out.str (); output = out.str ();
return rc; return rc;

View file

@ -537,7 +537,7 @@
#define STRING_CMD_CUSTOM_COUNT "1 task" #define STRING_CMD_CUSTOM_COUNT "1 task"
#define STRING_CMD_CUSTOM_COUNTN "{1} tasks" #define STRING_CMD_CUSTOM_COUNTN "{1} tasks"
#define STRING_CMD_CUSTOM_TRUNCATED "truncated to {1} lines" #define STRING_CMD_CUSTOM_TRUNCATED "truncated to {1} lines"
#define STRING_CMD_TIMESHEET_USAGE "Weekly summary of completed and started tasks" #define STRING_CMD_TIMESHEET_USAGE "Summary of completed and started tasks"
#define STRING_CMD_TIMESHEET_STARTED "Started ({1} tasks)" #define STRING_CMD_TIMESHEET_STARTED "Started ({1} tasks)"
#define STRING_CMD_TIMESHEET_DONE "Completed ({1} tasks)" #define STRING_CMD_TIMESHEET_DONE "Completed ({1} tasks)"
#define STRING_CMD_BURN_USAGE_M "Shows a graphical burndown chart, by month" #define STRING_CMD_BURN_USAGE_M "Shows a graphical burndown chart, by month"

View file

@ -539,7 +539,7 @@
#define STRING_CMD_CUSTOM_COUNT "1 task" #define STRING_CMD_CUSTOM_COUNT "1 task"
#define STRING_CMD_CUSTOM_COUNTN "{1} tasks" #define STRING_CMD_CUSTOM_COUNTN "{1} tasks"
#define STRING_CMD_CUSTOM_TRUNCATED "truncated to {1} lines" #define STRING_CMD_CUSTOM_TRUNCATED "truncated to {1} lines"
#define STRING_CMD_TIMESHEET_USAGE "Weekly summary of completed and started tasks" #define STRING_CMD_TIMESHEET_USAGE "Summary of completed and started tasks"
#define STRING_CMD_TIMESHEET_STARTED "Started ({1} tasks)" #define STRING_CMD_TIMESHEET_STARTED "Started ({1} tasks)"
#define STRING_CMD_TIMESHEET_DONE "Completed ({1} tasks)" #define STRING_CMD_TIMESHEET_DONE "Completed ({1} tasks)"
#define STRING_CMD_BURN_USAGE_M "Shows a graphical burndown chart, by month" #define STRING_CMD_BURN_USAGE_M "Shows a graphical burndown chart, by month"

View file

@ -70,32 +70,27 @@ class TestTimesheet(TestCase):
cls.t("log C1 entry:{0} end:{1}".format(fourteen, seven)) cls.t("log C1 entry:{0} end:{1}".format(fourteen, seven))
cls.t("log C2 entry:{0} end:{0}".format(fourteen)) cls.t("log C2 entry:{0} end:{0}".format(fourteen))
def test_one_week(self):
"""One week of started and completed"""
code, out, err = self.t("timesheet")
expected = re.compile("Completed.+C0.+Started.+PS0", re.DOTALL)
self.assertRegexpMatches(out, expected)
def test_two_weeks(self):
"""Two weeks of started and completed"""
code, out, err = self.t("timesheet 2")
expected = re.compile(
"Completed.+C0.+Started.+PS0.+"
"Completed.+C1.+Started.+PS1", re.DOTALL)
self.assertRegexpMatches(out, expected)
def test_three_weeks(self): def test_three_weeks(self):
"""Three weeks of started and completed""" """Three weeks of started and completed"""
code, out, err = self.t("timesheet 3") code, out, err = self.t("timesheet")
expected = re.compile( expected = re.compile(
"Completed.+C0.+Started.+PS0.+" "Started.+PS2.+Completed.+C2.+"
"Completed.+C1.+Started.+PS1.+" "Started.+PS1.+Completed.+C1.+"
"Completed.+C2.+Started.+PS2", re.DOTALL) "Started.+PS0.+Completed.+C0", re.DOTALL)
self.assertRegexpMatches(out, expected) self.assertRegexpMatches(out, expected)
def test_one_week(self):
"""One week of started and completed"""
# This is the default filter, reduced from 4 weeks to 1.
code, out, err = self.t("timesheet (+PENDING and start.after:now-1wk) or (+COMPLETED and end.after:now-1wk)")
expected = re.compile("Started.+PS0.+Completed.+C0", re.DOTALL)
self.assertRegexpMatches(out, expected)
self.assertNotIn("PS1", out)
self.assertNotIn("PS2", out)
self.assertNotIn("C1", out)
self.assertNotIn("C2", out)
if __name__ == "__main__": if __name__ == "__main__":
from simpletap import TAPTestRunner from simpletap import TAPTestRunner