diff --git a/doc/man/task.1.in b/doc/man/task.1.in index bed3c8f3a..b583be9d1 100644 --- a/doc/man/task.1.in +++ b/doc/man/task.1.in @@ -165,6 +165,10 @@ the 'burndown.weekly' report. Is affected by the context. .B task burndown.monthly Shows a graphical burndown chart, by month. Is affected by the context. +.TP +.B task burndown.annual +Shows a graphical burndown chart, by year. Is affected by the context. + .TP .B task calendar [due| |] [y] Shows a monthly calendar with due tasks marked. Shows one horizontal line of diff --git a/src/commands/CmdBurndown.cpp b/src/commands/CmdBurndown.cpp index b0045cab8..7ec55f867 100644 --- a/src/commands/CmdBurndown.cpp +++ b/src/commands/CmdBurndown.cpp @@ -25,22 +25,21 @@ //////////////////////////////////////////////////////////////////////////////// #include -// cmake.h include header must come first - +// cmake.h include must come first. #include #include #include #include #include #include -#include -#include #include #include #include #include +#include #include +#include // Helper macro. #define LOC(y, x) (((y) * (_width + 1)) + (x)) @@ -49,42 +48,26 @@ class Bar { public: Bar() = default; - Bar(const Bar&); - Bar& operator=(const Bar&); ~Bar() = default; - public: - int _offset{0}; // from left of chart - std::string _major_label{""}; // x-axis label, major (year/-/month) - std::string _minor_label{""}; // x-axis label, minor (month/week/day) - int _pending{0}; // Number of pending tasks in period - int _started{0}; // Number of started tasks in period - int _done{0}; // Number of done tasks in period - int _added{0}; // Number added in period - int _removed{0}; // Number removed in period + int _offset{0}; // from left of chart + std::string _major_label; // x-axis label, major (year/-/month) + std::string _minor_label; // x-axis label, minor (month/week/day) + int _pending{0}; // Number of pending tasks in period + int _started{0}; // Number of started tasks in period + int _done{0}; // Number of done tasks in period }; //////////////////////////////////////////////////////////////////////////////// -Bar::Bar(const Bar& other) { *this = other; } - -//////////////////////////////////////////////////////////////////////////////// -Bar& Bar::operator=(const Bar& other) { - if (this != &other) { - _offset = other._offset; - _major_label = other._major_label; - _minor_label = other._minor_label; - _pending = other._pending; - _started = other._started; - _done = other._done; - _added = other._added; - _removed = other._removed; - } - - return *this; -} - -//////////////////////////////////////////////////////////////////////////////// -// Data gathering algorithm: +// Task counting pattern. +// In the original version of the burndown chart, the names here corresponded +// literally to the terms in the gathering algorithm. Following a rewrite to +// pre-compute the epochs, the relationship of these dates is more indirect +// (see code comments throughout the file), but the broad semantics remain +// similar. +// +// If you're familiar with the old version of the burndown code, note that +// we no longer count deleted tasks except for peak calculation. // // e = entry // s = start @@ -94,70 +77,71 @@ Bar& Bar::operator=(const Bar& other) { // // ID 30 31 01 02 03 04 05 06 07 08 09 10 // -- ------------------------------------ -// 1 e-----s--C -// 2 e--s-----D -// 3 e-----s--------------> -// 4 e-----------------> -// 5 e-----> +// 1 e-----s--C completed +// 2 e--s-----D deleted (peak count only) +// 3 e-----s--------------> pending, started +// 4 e-----------------> pending +// 5 e-----> pending // -- ------------------------------------ -// PP 1 2 3 3 2 2 2 3 3 3 -// SS 2 1 1 1 1 1 1 1 +// PP 1 1 2 2 1 1 1 2 2 2 +// SS 1 1 1 1 1 1 // DD 1 1 1 1 1 1 1 // -- ------------------------------------ -// -// 5 | SS DD DD DD DD -// 4 | SS SS DD DD DD SS SS SS -// 3 | PP PP SS SS SS PP PP PP -// 2 | PP PP PP PP PP PP PP PP PP -// 1 | PP PP PP PP PP PP PP PP PP PP -// 0 +------------------------------------- -// 30 31 01 02 03 04 05 06 07 08 09 10 -// Oct Nov -// +// Peak 1 2 3 4 3 2 2 3 3 3 + +// Forward-declared so Chart can reference it in its method signatures; +// defined below findTaskEpochRange. +struct TaskEpochRange; +Datetime quantize(const Datetime&, char); + class Chart { public: Chart(char); - Chart(const Chart&); // Unimplemented - Chart& operator=(const Chart&); // Unimplemented + Chart(const Chart&) = delete; + Chart& operator=(const Chart&) = delete; ~Chart() = default; - void scan(std::vector&); - void scanForPeak(std::vector&); + void accumulateTasks(const std::vector&, const std::vector&, + time_t now_epoch, bool cumulative); + void generateBars(); + void finalize(); std::string render(); + void buildPeakRange(time_t, time_t); private: - void generateBars(); - void optimizeGrid(); - Datetime quantize(const Datetime&, char); - + void optimizeGrid(std::string& grid); Datetime increment(const Datetime&, char); Datetime decrement(const Datetime&, char); void maxima(); - void yLabels(std::vector&); + std::vector yLabels(); void calculateRates(); unsigned round_up_to(unsigned, unsigned); unsigned burndown_size(unsigned); public: - int _width{}; // Terminal width - int _height{}; // Terminal height - int _graph_width{}; // Width of plot area - int _graph_height{}; // Height of plot area - int _max_value{0}; // Largest combined bar value - int _max_label{1}; // Longest y-axis label - std::vector _labels{}; // Y-axis labels - int _estimated_bars{}; // Estimated bar count - int _actual_bars{0}; // Calculated bar count - std::map _bars{}; // Epoch-indexed set of bars - Datetime _earliest{}; // Date of earliest estimated bar - int _carryover_done{0}; // Number of 'done' tasks prior to chart range - char _period{}; // D, W, M - std::string _grid{}; // String representing grid of characters - time_t _peak_epoch{}; // Quantized (D) date of highest pending peak - int _peak_count{0}; // Corresponding peak pending count - int _current_count{0}; // Current pending count - float _net_fix_rate{0.0}; // Calculated fix rate - std::string _completion{}; // Estimated completion date + int _width{}; // Terminal width + int _height{}; // Terminal height + int _graph_width{}; // Width of plot area + int _graph_height{}; // Height of plot area + int _max_value{0}; // Largest value of pending+started+done + // +carried over done + int _max_label{1}; // Longest y-axis label + int _estimated_bars{}; // Max number of bars that fit the terminal + int _actual_bars{0}; // Max no. of bars that fit given + // y-axis label width + std::map _bars{}; // Epoch-indexed set of bars + Datetime _earliest{}; // Date of earliest estimated bar + int _carryover_done{0}; // Number of 'done' tasks prior to chart range + char _period{}; // D, W, M + std::vector _peak_day_epochs{}; // All consecutive day epochs (for peak range) + std::vector _peak_diff{}; // Difference array for peak counting, + // quantized by day + std::unordered_map _peak_day_index{}; // a map of per-day epochs to _peak_diff + time_t _peak_day{}; // Day of highest pending peak + int _peak_count{0}; // Corresponding peak pending count + int _current_count{0}; // Current count of tasks without end date + float _net_fix_rate{0.0f}; // Calculated fix rate (tasks/day) + std::string _completion{}; // Estimated completion date }; //////////////////////////////////////////////////////////////////////////////// @@ -171,138 +155,169 @@ Chart::Chart(char type) { _graph_height = _height - 7; _graph_width = _width - _max_label - 14; - // Estimate how many 'bars' can be dsplayed. This will help subset a + // Estimate how many 'bars' can be displayed. This will help subset a // potentially enormous data set. _estimated_bars = (_width - 1 - 14) / 3; _period = type; } -//////////////////////////////////////////////////////////////////////////////// -// Scan all tasks, quantize the dates by day, and find the peak pending count -// and corresponding epoch. -void Chart::scanForPeak(std::vector& tasks) { - std::map pending; - _current_count = 0; - - for (auto& task : tasks) { - // The entry date is when the counting starts. - Datetime entry(task.get_date("entry")); - - Datetime end; - if (task.has("end")) - end = Datetime(task.get_date("end")); - else - ++_current_count; - - while (entry < end) { - time_t epoch = quantize(entry.toEpoch(), 'D').toEpoch(); - if (pending.find(epoch) != pending.end()) - ++pending[epoch]; - else - pending[epoch] = 1; - - entry = increment(entry, 'D'); - } - } +// - Bar fields (first/last/entry/end_epoch): quantized by chart period +// (D/W/M), used for rendered bars. +// - Peak fields (peak_entry/peak_end): quantized by day, used for the +// peak pending count. +struct TaskEpochRange { + time_t first_epoch; // leftmost bar this task touches + time_t last_epoch; // rightmost bar this task touches + time_t entry_epoch; // quantized entry by period + std::optional end_epoch; // quantized end by period + // nullopt when task has no end + time_t peak_entry; // quantized entry by day + time_t peak_end; // quantized end by day, or today when no end + std::optional start_epoch; // quantized start by period, + // nullopt if no start +}; - // Find the peak and peak date. - for (auto& count : pending) { - if (count.second > _peak_count) { - _peak_count = count.second; - _peak_epoch = count.first; - } +//////////////////////////////////////////////////////////////////////////////// +// Compute the bar-epoch range and quantized attribute epochs for one task. +// Used by scan_epochs to size the peak range, and by accumulateTasks to +// pre-skip zero-contribution tasks. Per-bar contributions (pending/ +// started/done) are computed in accumulateTasks from entry_epoch and +// end_epoch, because last_epoch is not always the per-bar boundary, +// eg. for a completed task in cumulative mode, last_epoch extends to today. +// now_epoch is now quantized by chart period +// now_day_epoch is now quantized by day +static TaskEpochRange findTaskEpochRange(const Task& task, time_t now_epoch, time_t now_day_epoch, + char period, bool cumulative) { + Datetime entry_date(task.get_date("entry")); + time_t entry_epoch = quantize(entry_date, period).toEpoch(); + // The peak count is always quantized by day, regardless of chart period. + time_t peak_entry = quantize(entry_date, 'D').toEpoch(); + + std::optional start_epoch; + if (task.has("start")) start_epoch = quantize(Datetime(task.get_date("start")), period).toEpoch(); + + // Quantize end epochs once. + // When absent, peak_end defaults to today. + std::optional end_epoch; + time_t peak_end = now_day_epoch; + if (task.has("end")) { + Datetime end_date(task.get_date("end")); + end_epoch = quantize(end_date, period).toEpoch(); + peak_end = quantize(end_date, 'D').toEpoch(); } -} -//////////////////////////////////////////////////////////////////////////////// -void Chart::scan(std::vector& tasks) { - generateBars(); + Task::status status = task.getStatus(); - // Not quantized, so that "while (xxx < now)" is inclusive. - Datetime now; + // Pending/waiting tasks extend to today if they lack an end. + if (status == Task::pending || status == Task::waiting) { + time_t last_epoch = end_epoch.value_or(now_epoch); + return {entry_epoch, last_epoch, entry_epoch, end_epoch, peak_entry, peak_end, start_epoch}; + } - time_t epoch; - auto& config = Context::getContext().config; - bool cumulative; - if (config.has("burndown.cumulative")) { - cumulative = config.getBoolean("burndown.cumulative"); - } else { - cumulative = true; + // Completed tasks extend to end (non-cumulative) or today (cumulative). + if (status == Task::completed) { + time_t e = *end_epoch; // completed tasks always have an end + time_t first_epoch = std::min(entry_epoch, e); // we handle the case where + // a task has an end before + // entry for some reason + time_t last_epoch = cumulative ? std::max(e, now_epoch) : std::max(e, entry_epoch); + return {first_epoch, last_epoch, entry_epoch, end_epoch, peak_entry, peak_end, start_epoch}; } - for (auto& task : tasks) { - // The entry date is when the counting starts. - Datetime from = quantize(Datetime(task.get_date("entry")), _period); - epoch = from.toEpoch(); + // Deleted/recurring tasks don't contribute to bars, but they do to the + // peak count. + time_t last_epoch = end_epoch ? std::max(*end_epoch, entry_epoch) : entry_epoch; + return {entry_epoch, last_epoch, entry_epoch, end_epoch, peak_entry, peak_end, start_epoch}; +} - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._added; +//////////////////////////////////////////////////////////////////////////////// +void Chart::accumulateTasks(const std::vector& tasks, + const std::vector& ranges, time_t now_epoch, + bool cumulative) { + // A helper that maps epochs to their indices, and then keeps a difference + // array via _peak_diff. This array is then prefix-summed by finalize(), + // giving us the _peak_count for each day. The _peak_day_index is always + // quantized by day, regardless of the mode the chart runs in. + auto peak_add = [&](time_t first_day, time_t last_day) { + auto first_it = _peak_day_index.find(first_day); + auto last_it = _peak_day_index.find(last_day); + if (first_it == _peak_day_index.end() || last_it == _peak_day_index.end()) return; + size_t fi = first_it->second, li = last_it->second; + _peak_diff[fi]++; + if (li + 1 < _peak_diff.size()) _peak_diff[li + 1]--; + }; + + // Helper function that operates on each bar in [start, last], including + // values equal to last. + auto for_bars_through = [&](time_t start, time_t last, auto&& fn) { + auto it = _bars.lower_bound(start); + for (; it != _bars.end() && it->first <= last; ++it) fn(it->second); + }; + + // Helper function that operates on each bar in [start, end], + // excluding values equal to end. + auto for_bars_before = [&](time_t start, time_t end_excl, auto&& fn) { + auto it = _bars.lower_bound(start); + for (; it != _bars.end() && it->first < end_excl; ++it) fn(it->second); + }; + + for (size_t i = 0; i < tasks.size(); ++i) { + // ranges is the taskEpochRange returned by findTaskEpochRange(). + const auto& r = ranges[i]; + const auto& task = tasks[i]; + + // Peak and _current_count are made regardless of the task status. + peak_add(r.peak_entry, r.peak_end); + if (!r.end_epoch) ++_current_count; - // e--> e--s--> - // ppp> pppsss> Task::status status = task.getStatus(); - if (status == Task::pending || status == Task::waiting) { - if (task.has("start")) { - Datetime start = quantize(Datetime(task.get_date("start")), _period); - while (from < start) { - epoch = from.toEpoch(); - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._pending; - from = increment(from, _period); - } - - while (from < now) { - epoch = from.toEpoch(); - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._started; - from = increment(from, _period); - } - } else { - while (from < now) { - epoch = from.toEpoch(); - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._pending; - from = increment(from, _period); - } - } - } - // e--C e--s--C - // pppd> pppsssd> - else if (status == Task::completed) { - // Truncate history so it starts at 'earliest' for completed tasks. - Datetime end = quantize(Datetime(task.get_date("end")), _period); - epoch = end.toEpoch(); + // Deleted/recurring tasks contribute to the peak and _current_count, + // but make no contribution to the chart's bars. + if (status == Task::deleted || status == Task::recurring) continue; - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._removed; - - while (from < end) { - epoch = from.toEpoch(); - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._pending; - from = increment(from, _period); + if (status == Task::pending || status == Task::waiting) { + // Pending tasks contribute to the pending bars through [entry, last], + // inclusive of values equal to last. For pending tasks, last_epoch will be + // either the now_epoch or end_epoch. + for_bars_through(r.entry_epoch, r.last_epoch, [](Bar& b) { ++b._pending; }); + + // If the task has a start date, bars from that date through last_epoch + // change from pending to started. + if (r.start_epoch) { + for_bars_through(*r.start_epoch, r.last_epoch, [](Bar& b) { + --b._pending; + ++b._started; + }); } + } else if (status == Task::completed) { + // Completed tasks are counted as pending exclusive of their + // end_epoch. The peak count, however, includes values equal to + // end_epoch. See findTaskEpochRange() for details of assignment. + for_bars_before(r.entry_epoch, *r.end_epoch, [](Bar& b) { ++b._pending; }); if (cumulative) { - while (from < now) { - epoch = from.toEpoch(); - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._done; - from = increment(from, _period); - } - - // Maintain a running total of 'done' tasks that are off the left of the - // chart. - if (end < _earliest) { - ++_carryover_done; - continue; - } - } - - else { - epoch = from.toEpoch(); - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._done; + // In cumulative mode, completed tasks range through + // [max(entry, end), today], including values equal to today. + // Nb: now_epoch is quantized by the period of the chart mode, + // ie. daily/weekly/monthly. + time_t done_start_epoch = std::max(r.entry_epoch, *r.end_epoch); + for_bars_through(done_start_epoch, now_epoch, [](Bar& b) { ++b._done; }); + + // If the end date of a completed task is before the leftmost + // rendered bar we need to carry over the count. _earliest is simply + // the earliest epoch in the dataset. + if (*r.end_epoch < _earliest.toEpoch()) ++_carryover_done; + } else { + // In non-cumulative mode, each end date of a completed task counts + // only for the bars of that day. + time_t done_epoch = std::max(r.entry_epoch, *r.end_epoch); + auto done_bar = _bars.find(done_epoch); + if (done_bar != _bars.end()) ++done_bar->second._done; } } } - - // Size the data. - maxima(); } //////////////////////////////////////////////////////////////////////////////// @@ -339,86 +354,76 @@ std::string Chart::render() { if (_max_value == 0) Context::getContext().footnote("No matches."); // Create a grid, folded into a string. - _grid = ""; - for (int i = 0; i < _height; ++i) _grid += std::string(_width, ' ') + '\n'; + std::string grid; + grid.reserve(static_cast(_height) * (_width + 1)); + for (int i = 0; i < _height; ++i) grid += std::string(_width, ' ') + '\n'; // Title. - std::string title = _period == 'D' ? "Daily" : _period == 'W' ? "Weekly" : "Monthly"; + std::string title = _period == 'D' ? "Daily" + : _period == 'W' ? "Weekly" + : _period == 'M' ? "Monthly" + : _period == 'Y' ? "Annual" + : "Monthly"; title += std::string(" Burndown"); - _grid.replace(LOC(0, (_width - title.length()) / 2), title.length(), title); + grid.replace(LOC(0, (_width - title.length()) / 2), title.length(), title); // Legend. - _grid.replace(LOC(_graph_height / 2 - 1, _width - 10), 10, "DD " + leftJustify("Done", 7)); - _grid.replace(LOC(_graph_height / 2, _width - 10), 10, "SS " + leftJustify("Started", 7)); - _grid.replace(LOC(_graph_height / 2 + 1, _width - 10), 10, "PP " + leftJustify("Pending", 7)); + grid.replace(LOC(_graph_height / 2 - 1, _width - 10), 10, "DD " + leftJustify("Done", 7)); + grid.replace(LOC(_graph_height / 2, _width - 10), 10, "SS " + leftJustify("Started", 7)); + grid.replace(LOC(_graph_height / 2 + 1, _width - 10), 10, "PP " + leftJustify("Pending", 7)); // Determine y-axis labelling. - std::vector _labels; - yLabels(_labels); - _max_label = (int)log10((double)_labels[2]) + 1; + auto labels = yLabels(); + // Digit count of the high y-axis label (avoids log10 FP). + _max_label = 1; + for (int t = labels[2]; t >= 10; t /= 10) ++_max_label; // Draw y-axis. - for (int i = 0; i < _graph_height; ++i) _grid.replace(LOC(i + 1, _max_label + 1), 1, "|"); + for (int i = 0; i < _graph_height; ++i) grid.replace(LOC(i + 1, _max_label + 1), 1, "|"); // Draw y-axis labels. char label[12]; - snprintf(label, 12, "%*d", _max_label, _labels[2]); - _grid.replace(LOC(1, _max_label - strlen(label)), strlen(label), label); - snprintf(label, 12, "%*d", _max_label, _labels[1]); - _grid.replace(LOC(1 + (_graph_height / 2), _max_label - strlen(label)), strlen(label), label); - _grid.replace(LOC(_graph_height + 1, _max_label - 1), 1, "0"); + snprintf(label, 12, "%*d", _max_label, labels[2]); + grid.replace(LOC(1, _max_label - strlen(label)), strlen(label), label); + snprintf(label, 12, "%*d", _max_label, labels[1]); + grid.replace(LOC(1 + (_graph_height / 2), _max_label - strlen(label)), strlen(label), label); + grid.replace(LOC(_graph_height + 1, _max_label - 1), 1, "0"); // Draw x-axis. - _grid.replace(LOC(_height - 6, _max_label + 1), 1, "+"); - _grid.replace(LOC(_height - 6, _max_label + 2), _graph_width, std::string(_graph_width, '-')); - - // Draw x-axis labels. - std::vector bars_in_sequence; - for (auto& bar : _bars) bars_in_sequence.push_back(bar.first); - - std::sort(bars_in_sequence.begin(), bars_in_sequence.end()); - std::string _major_label; - for (auto& seq : bars_in_sequence) { - Bar bar = _bars[seq]; + grid.replace(LOC(_height - 6, _max_label + 1), 1, "+"); + grid.replace(LOC(_height - 6, _max_label + 2), _graph_width, std::string(_graph_width, '-')); + // Draw x-axis labels. _bars is a std::map (already sorted by epoch). + std::string last_major; + for (const auto& [epoch, bar] : _bars) { // If it fits within the allowed space. if (bar._offset < _actual_bars) { - _grid.replace(LOC(_height - 5, _max_label + 3 + ((_actual_bars - bar._offset - 1) * 3)), - bar._minor_label.length(), bar._minor_label); + int col = _max_label + 3 + ((_actual_bars - bar._offset - 1) * 3); + grid.replace(LOC(_height - 5, col), bar._minor_label.length(), bar._minor_label); - if (_major_label != bar._major_label) - _grid.replace(LOC(_height - 4, _max_label + 2 + ((_actual_bars - bar._offset - 1) * 3)), - bar._major_label.length(), ' ' + bar._major_label); + if (last_major != bar._major_label) + grid.replace(LOC(_height - 4, col - 1), bar._major_label.length(), ' ' + bar._major_label); - _major_label = bar._major_label; + last_major = bar._major_label; } } // Draw bars. - for (auto& seq : bars_in_sequence) { - Bar bar = _bars[seq]; - + for (const auto& [epoch, bar] : _bars) { // If it fits within the allowed space. if (bar._offset < _actual_bars) { - int pending = (bar._pending * _graph_height) / _labels[2]; - int started = ((bar._pending + bar._started) * _graph_height) / _labels[2]; - int done = ((bar._pending + bar._started + bar._done + _carryover_done) * _graph_height) / - _labels[2]; - - for (int b = 0; b < pending; ++b) - _grid.replace( - LOC(_graph_height - b, _max_label + 3 + ((_actual_bars - bar._offset - 1) * 3)), 2, - "PP"); - - for (int b = pending; b < started; ++b) - _grid.replace( - LOC(_graph_height - b, _max_label + 3 + ((_actual_bars - bar._offset - 1) * 3)), 2, - "SS"); - - for (int b = started; b < done; ++b) - _grid.replace( - LOC(_graph_height - b, _max_label + 3 + ((_actual_bars - bar._offset - 1) * 3)), 2, - "DD"); + int col = _max_label + 3 + ((_actual_bars - bar._offset - 1) * 3); + int pending = (bar._pending * _graph_height) / labels[2]; + int started = ((bar._pending + bar._started) * _graph_height) / labels[2]; + int done = + ((bar._pending + bar._started + bar._done + _carryover_done) * _graph_height) / labels[2]; + + auto draw_segment = [&](int from, int to, const char* tag) { + for (int b = from; b < to; ++b) grid.replace(LOC(_graph_height - b, col), 2, tag); + }; + draw_segment(0, pending, "PP"); + draw_segment(pending, started, "SS"); + draw_segment(started, done, "DD"); } } @@ -428,64 +433,77 @@ std::string Chart::render() { if (_net_fix_rate != 0.0) snprintf(rate, 12, "%.1f/d", _net_fix_rate); else - strcpy(rate, "-"); + snprintf(rate, 12, "-"); - _grid.replace(LOC(_height - 2, _max_label + 3), 22 + strlen(rate), - std::string("Net Fix Rate: ") + rate); + grid.replace(LOC(_height - 2, _max_label + 3), 22 + strlen(rate), + std::string("Net Fix Rate: ") + rate); // Draw completion date. if (_completion.length()) - _grid.replace(LOC(_height - 1, _max_label + 3), 22 + _completion.length(), - "Estimated completion: " + _completion); - - optimizeGrid(); - - if (Context::getContext().color()) { - // Colorize the grid. - Color color_pending(Context::getContext().config.get("color.burndown.pending")); - Color color_done(Context::getContext().config.get("color.burndown.done")); - Color color_started(Context::getContext().config.get("color.burndown.started")); - - // Replace DD, SS, PP with colored strings. - std::string::size_type i; - while ((i = _grid.find("PP")) != std::string::npos) - _grid.replace(i, 2, color_pending.colorize(" ")); - - while ((i = _grid.find("SS")) != std::string::npos) - _grid.replace(i, 2, color_started.colorize(" ")); - - while ((i = _grid.find("DD")) != std::string::npos) - _grid.replace(i, 2, color_done.colorize(" ")); - } else { - // Replace DD, SS, PP with ./+/X strings. - std::string::size_type i; - while ((i = _grid.find("PP")) != std::string::npos) _grid.replace(i, 2, " X"); - - while ((i = _grid.find("SS")) != std::string::npos) _grid.replace(i, 2, " +"); - - while ((i = _grid.find("DD")) != std::string::npos) _grid.replace(i, 2, " ."); + grid.replace(LOC(_height - 1, _max_label + 3), 22 + _completion.length(), + "Estimated completion: " + _completion); + + optimizeGrid(grid); + + // Scan the grid char-by-char and replace the PP/SS/DD markers with + // colored or plain equivalents. Non-marker characters are copied. + const bool use_color = Context::getContext().color(); + Color color_pending(Context::getContext().config.get("color.burndown.pending")); + Color color_done(Context::getContext().config.get("color.burndown.done")); + Color color_started(Context::getContext().config.get("color.burndown.started")); + + std::string result; + result.reserve(grid.size() * 2); + std::string::size_type pos = 0; + while (pos < grid.size()) { + if (pos + 1 < grid.size()) { + char c1 = grid[pos], c2 = grid[pos + 1]; + if (c1 == 'P' && c2 == 'P') { + result += use_color ? color_pending.colorize(" ") : " X"; + pos += 2; + continue; + } + if (c1 == 'S' && c2 == 'S') { + result += use_color ? color_started.colorize(" ") : " +"; + pos += 2; + continue; + } + if (c1 == 'D' && c2 == 'D') { + result += use_color ? color_done.colorize(" ") : " ."; + pos += 2; + continue; + } + } + result += grid[pos]; + ++pos; } - return _grid; + return result; } //////////////////////////////////////////////////////////////////////////////// -// _grid =~ /\s+$//g -void Chart::optimizeGrid() { +// grid =~ /\s+$//g +void Chart::optimizeGrid(std::string& grid) { std::string::size_type ws; - while ((ws = _grid.find(" \n")) != std::string::npos) { + while ((ws = grid.find(" \n")) != std::string::npos) { auto non_ws = ws; - while (_grid[non_ws] == ' ') --non_ws; + while (non_ws > 0 && grid[non_ws] == ' ') --non_ws; + if (grid[non_ws] == ' ') { + // The entire line up to ws is spaces; nothing to trim before. + grid.replace(0, ws + 1, "\n"); + continue; + } - _grid.replace(non_ws + 1, ws - non_ws + 1, "\n"); + grid.replace(non_ws + 1, ws - non_ws + 1, "\n"); } } //////////////////////////////////////////////////////////////////////////////// -Datetime Chart::quantize(const Datetime& input, char period) { +Datetime quantize(const Datetime& input, char period) { if (period == 'D') return input.startOfDay(); if (period == 'W') return input.startOfWeek(); if (period == 'M') return input.startOfMonth(); + if (period == 'Y') return input.startOfYear(); return input; } @@ -531,11 +549,39 @@ Datetime Chart::increment(const Datetime& input, char period) { ++y; } break; + + case 'Y': + d = 1; + m = 1; + ++y; + break; + + default: + break; } return Datetime(y, m, d, 0, 0, 0); } +//////////////////////////////////////////////////////////////////////////////// +// Build the epoch range used for peak counting. The peak is always quantized +// by day regardless of chart mode. +void Chart::buildPeakRange(time_t earliest_day, time_t latest_day) { + _peak_diff.clear(); + _peak_day_epochs.clear(); + _peak_day_index.clear(); + + Datetime cursor(earliest_day); + Datetime end(latest_day); + while (cursor <= end) { + time_t ep = cursor.toEpoch(); + _peak_day_index[ep] = _peak_diff.size(); + _peak_day_epochs.push_back(ep); + _peak_diff.push_back(0); + cursor = increment(cursor, 'D'); + } +} + //////////////////////////////////////////////////////////////////////////////// Datetime Chart::decrement(const Datetime& input, char period) { // Move to the previous period. @@ -574,31 +620,33 @@ Datetime Chart::decrement(const Datetime& input, char period) { --y; } break; + + case 'Y': + d = 1; + m = 1; + --y; + break; + + default: + break; } return Datetime(y, m, d, 0, 0, 0); } //////////////////////////////////////////////////////////////////////////////// -// Do '_bars[epoch] = Bar' for every bar that may appear on a chart. +// Create one bar per period going backwards from today. Each bar is keyed by +// the quantized epoch of the chart mode (daily/weekly/monthly). _earliest is +// the leftmost bar's epoch, used for carrying over the done count in cumulative +// mode. void Chart::generateBars() { - Bar bar; - - // Determine the last bar date. - Datetime cursor; - switch (_period) { - case 'D': - cursor = Datetime().startOfDay(); - break; - case 'W': - cursor = Datetime().startOfWeek(); - break; - case 'M': - cursor = Datetime().startOfMonth(); - break; - } + Bar bar; // future contributor: if you change the logic used for counting, + // such that this function changes the counts, be mindful: + // because we copy the struct, your state would be carried through + // into each loop. + Datetime cursor = quantize(Datetime(), _period); - // Iterate and determine all the other bar dates. + // Iterate backwards from today, generating labels for each bar. char str[12]; for (int i = 0; i < _estimated_bars; ++i) { // Create the major and minor labels. @@ -627,12 +675,19 @@ void Chart::generateBars() { snprintf(str, 12, "%02d", cursor.month()); bar._minor_label = str; break; + + case 'Y': // 2-digit year abbreviation + snprintf(str, 12, "%02d", cursor.year() % 100); + bar._minor_label = str; + bar._major_label = ""; + break; } bar._offset = i; _bars[cursor.toEpoch()] = bar; - // Record the earliest date, for use as a cutoff when scanning data. + // Record the earliest date, used as the cutoff for carrying over done + // counts in cumulative mode. _earliest = cursor; // Move to the previous period. @@ -640,20 +695,43 @@ void Chart::generateBars() { } } +//////////////////////////////////////////////////////////////////////////////// +// Compute the peak pending count from the accumulated quantized data, then +// size the bar data for rendering. +void Chart::finalize() { + // _peak_diff is a difference array, here we prefix-sum it to get the + // actual counts. + _peak_count = 0; + _peak_day = 0; + int running = 0; + for (size_t i = 0; i < _peak_diff.size(); ++i) { + running += _peak_diff[i]; + if (running > _peak_count) { + _peak_count = running; + _peak_day = _peak_day_epochs[i]; + } + } + maxima(); +} + //////////////////////////////////////////////////////////////////////////////// void Chart::maxima() { _max_value = 0; _max_label = 1; for (auto& bar : _bars) { - // Determine _max_label. + // pending + started + done + carryover. int total = bar.second._pending + bar.second._started + bar.second._done + _carryover_done; // Determine _max_value. if (total > _max_value) _max_value = total; - int length = (int)log10((double)total) + 1; - if (length > _max_label) _max_label = length; + // Count of the tallest bar. Shouldn't == 0. + if (total > 0) { + int length = 1; + for (int t = total; t >= 10; t /= 10) ++length; + if (length > _max_label) _max_label = length; + } } // How many bars can be shown? @@ -664,14 +742,12 @@ void Chart::maxima() { //////////////////////////////////////////////////////////////////////////////// // Given the vertical chart area size (graph_height), the largest value // (_max_value), populate a vector of labels for the y axis. -void Chart::yLabels(std::vector& labels) { - // Calculate may Y using a nice algorithm that rounds the data. +std::vector Chart::yLabels() { + // Calculate max Y using a nice algorithm that rounds the data. int high = burndown_size(_max_value); int half = high / 2; - labels.push_back(0); - labels.push_back(half); - labels.push_back(high); + return {0, half, high}; } //////////////////////////////////////////////////////////////////////////////// @@ -681,7 +757,7 @@ void Chart::calculateRates() { // are calculated. This may also help debugging. std::stringstream peak_message; peak_message << "Chart::calculateRates Maximum of " << _peak_count << " pending tasks on " - << (Datetime(_peak_epoch).toISO()) << ", with currently " << _current_count + << (Datetime(_peak_day).toISO()) << ", with currently " << _current_count << " pending tasks"; Context::getContext().debug(peak_message.str()); @@ -689,17 +765,20 @@ void Chart::calculateRates() { // rates or estimated completion date. if (_current_count == 0) return; + Datetime now; // unquantized current time - fix rate should be independent + // of chart quantization mode. + // If there is a net fix rate, and the peak was at least three days ago. - Datetime now; - Datetime peak(_peak_epoch); + Datetime peak(_peak_day); if (_peak_count > _current_count && (now - peak) > 3 * 86400) { - // Fixes per second. Not a large number. - auto fix_rate = 1.0 * (_peak_count - _current_count) / (now.toEpoch() - _peak_epoch); - _net_fix_rate = fix_rate * 86400; + // Fixes per second. Not a large number. Multiplied by 86400 to get + // fixes per day. + double fix_rate = 1.0 * (_peak_count - _current_count) / (now.toEpoch() - _peak_day); + _net_fix_rate = static_cast(fix_rate * 86400); std::stringstream rate_message; rate_message << "Chart::calculateRates Net reduction is " << (_peak_count - _current_count) - << " tasks in " << Duration(now.toEpoch() - _peak_epoch).formatISO() << " = " + << " tasks in " << Duration(now.toEpoch() - _peak_day).formatISO() << " = " << _net_fix_rate << " tasks/d"; Context::getContext().debug(rate_message.str()); @@ -708,9 +787,9 @@ void Chart::calculateRates() { // Prefer dateformat.report over dateformat. std::string format = Context::getContext().config.get("dateformat.report"); - if (format == "") { + if (format.empty()) { format = Context::getContext().config.get("dateformat"); - if (format == "") format = "Y-M-D"; + if (format.empty()) format = "Y-M-D"; } _completion = end.toString(format) + " (" + delta.formatVague() + ')'; @@ -739,8 +818,9 @@ unsigned Chart::burndown_size(unsigned ntasks) { if (ntasks < 100) return round_up_to(ntasks, 20); // Choose the number from here rounded up to the nearest 10% of the next - // highest power of 10 or half of power of 10. - const auto count = (unsigned)log10(static_cast(std::numeric_limits::max())); + // highest power of 10 or half of power of 10. UINT_MAX has 10 digits, + // so we iterate from i=2 (we handled 5/10/50/100 above) to i=9. + constexpr unsigned count = 10; unsigned half = 500; unsigned full = 1000; @@ -759,102 +839,91 @@ unsigned Chart::burndown_size(unsigned ntasks) { } //////////////////////////////////////////////////////////////////////////////// -CmdBurndownMonthly::CmdBurndownMonthly() { - _keyword = "burndown.monthly"; - _usage = "task burndown.monthly"; - _description = "Shows a graphical burndown chart, by month"; - _read_only = true; - _displays_id = false; - _needs_gc = true; - _needs_recur_update = true; - _uses_context = true; - _accepts_filter = true; - _accepts_modifications = false; - _accepts_miscellaneous = false; - _category = Command::Category::graphs; -} - -//////////////////////////////////////////////////////////////////////////////// -int CmdBurndownMonthly::execute(std::string& output) { - int rc = 0; - - // Scan the pending tasks, applying any filter. - Filter filter; - std::vector filtered; - filter.subset(filtered); - - // Create a chart, scan the tasks, then render. - Chart chart('M'); - chart.scanForPeak(filtered); - chart.scan(filtered); - output = chart.render(); - return rc; -} - -//////////////////////////////////////////////////////////////////////////////// -CmdBurndownWeekly::CmdBurndownWeekly() { - _keyword = "burndown.weekly"; - _usage = "task burndown.weekly"; - _description = "Shows a graphical burndown chart, by week"; - _read_only = true; - _displays_id = false; - _needs_gc = true; - _needs_recur_update = true; - _uses_context = true; - _accepts_filter = true; - _accepts_modifications = false; - _accepts_miscellaneous = false; - _category = Command::Category::graphs; -} - -//////////////////////////////////////////////////////////////////////////////// -int CmdBurndownWeekly::execute(std::string& output) { - int rc = 0; - - // Scan the pending tasks, applying any filter. - Filter filter; - std::vector filtered; - filter.subset(filtered); - - // Create a chart, scan the tasks, then render. - Chart chart('W'); - chart.scanForPeak(filtered); - chart.scan(filtered); - output = chart.render(); - return rc; -} - -//////////////////////////////////////////////////////////////////////////////// -CmdBurndownDaily::CmdBurndownDaily() { - _keyword = "burndown.daily"; - _usage = "task burndown.daily"; - _description = "Shows a graphical burndown chart, by day"; - _read_only = true; - _displays_id = false; - _needs_gc = true; - _needs_recur_update = true; - _uses_context = true; - _accepts_filter = true; - _accepts_modifications = false; - _accepts_miscellaneous = false; - _category = Command::Category::graphs; -} +static int runBurndown(char period, std::string& output) { + Chart chart(period); // size the chart + chart.generateBars(); // create the bar map from today backwards + + const auto& args = Context::getContext().cli2._args; + bool has_filter = + std::any_of(args.begin(), args.end(), [](const auto& a) { return a.hasTag("FILTER"); }); + + // now quantized by chart period + const time_t now_epoch = quantize(Datetime(), period).toEpoch(); + // now quantized by day, for peak counting + const time_t now_day_epoch = quantize(Datetime(), 'D').toEpoch(); + auto& cfg = Context::getContext().config; + bool cumulative = cfg.has("burndown.cumulative") ? cfg.getBoolean("burndown.cumulative") : true; + + // First pass: walk all tasks to find the peak range quantized by day, + // cache each task's taskEpochRange so accumulateTasks doesn't repeat + // quantization work. + // Returns {ranges, earliest_day, latest_day} for the task vector. + struct ScanResult { + std::vector ranges; + time_t earliest_day; + time_t latest_day; + }; + auto scan_epochs = [&](const std::vector& tasks, time_t earliest_day_in, + time_t latest_day_in) -> ScanResult { + ScanResult result; + result.ranges.reserve(tasks.size()); + result.earliest_day = earliest_day_in; + result.latest_day = latest_day_in; + for (const auto& task : tasks) { + auto r = findTaskEpochRange(task, now_epoch, now_day_epoch, period, cumulative); + result.ranges.push_back(r); + if (r.peak_entry < result.earliest_day) result.earliest_day = r.peak_entry; + if (r.peak_end > result.latest_day) result.latest_day = r.peak_end; + } + return result; + }; + + if (has_filter) { + Filter filter; + std::vector filtered; + filter.subset(filtered); + auto sr = scan_epochs(filtered, now_day_epoch, 0); + chart.buildPeakRange(sr.earliest_day, sr.latest_day); + chart.accumulateTasks(filtered, sr.ranges, now_epoch, cumulative); + } else { + const auto& pending = Context::getContext().tdb2.pending_tasks(); + const auto& completed = Context::getContext().tdb2.completed_tasks(); + auto srp = scan_epochs(pending, now_day_epoch, 0); + auto src = scan_epochs(completed, srp.earliest_day, srp.latest_day); + chart.buildPeakRange(src.earliest_day, src.latest_day); + chart.accumulateTasks(pending, srp.ranges, now_epoch, cumulative); + chart.accumulateTasks(completed, src.ranges, now_epoch, cumulative); + } -//////////////////////////////////////////////////////////////////////////////// -int CmdBurndownDaily::execute(std::string& output) { - int rc = 0; - - // Scan the pending tasks, applying any filter. - Filter filter; - std::vector filtered; - filter.subset(filtered); - - // Create a chart, scan the tasks, then render. - Chart chart('D'); - chart.scanForPeak(filtered); - chart.scan(filtered); + chart.finalize(); output = chart.render(); - return rc; + return 0; } //////////////////////////////////////////////////////////////////////////////// +// All three CmdBurndown subclasses share the same construction parameters, +// differing only in keyword/usage/description and period. Macro is shorter than +// 30+ lines of member initializations. +#define CMDBURNDOWN_CTOR(cls_name, period_char, period_word) \ + cls_name::cls_name() { \ + _keyword = "burndown." period_word; \ + _usage = "task burndown." period_word; \ + _description = "Shows a graphical burndown chart, by " period_word; \ + _read_only = true; \ + _displays_id = false; \ + _needs_gc = true; \ + _needs_recur_update = true; \ + _uses_context = true; \ + _accepts_filter = true; \ + _accepts_modifications = false; \ + _accepts_miscellaneous = false; \ + _category = Command::Category::graphs; \ + } \ + int cls_name::execute(std::string& output) { return runBurndown(period_char, output); } + +CMDBURNDOWN_CTOR(CmdBurndownMonthly, 'M', "monthly") +CMDBURNDOWN_CTOR(CmdBurndownWeekly, 'W', "weekly") +CMDBURNDOWN_CTOR(CmdBurndownDaily, 'D', "daily") +CMDBURNDOWN_CTOR(CmdBurndownAnnual, 'Y', "annual") + +#undef CMDBURNDOWN_CTOR diff --git a/src/commands/CmdBurndown.h b/src/commands/CmdBurndown.h index 818b01ea2..ea350aaa9 100644 --- a/src/commands/CmdBurndown.h +++ b/src/commands/CmdBurndown.h @@ -31,6 +31,12 @@ #include +class CmdBurndownAnnual : public Command { + public: + CmdBurndownAnnual(); + int execute(std::string&); +}; + class CmdBurndownMonthly : public Command { public: CmdBurndownMonthly(); diff --git a/src/commands/CmdReports.cpp b/src/commands/CmdReports.cpp index 4aa65e712..162ff94da 100644 --- a/src/commands/CmdReports.cpp +++ b/src/commands/CmdReports.cpp @@ -65,6 +65,7 @@ int CmdReports::execute(std::string& output) { } // Add known reports. + reports.push_back("burndown.annual"); reports.push_back("burndown.daily"); reports.push_back("burndown.monthly"); reports.push_back("burndown.weekly"); diff --git a/src/commands/Command.cpp b/src/commands/Command.cpp index 3e52a4389..7a6619658 100644 --- a/src/commands/Command.cpp +++ b/src/commands/Command.cpp @@ -101,6 +101,8 @@ void Command::factory(std::map& all) { all[c->keyword()] = c; c = new CmdAppend(); all[c->keyword()] = c; + c = new CmdBurndownAnnual(); + all[c->keyword()] = c; c = new CmdBurndownDaily(); all[c->keyword()] = c; c = new CmdBurndownMonthly(); diff --git a/test/burndown.test.py b/test/burndown.test.py index 9c31cd7a3..74d61d19e 100755 --- a/test/burndown.test.py +++ b/test/burndown.test.py @@ -95,6 +95,14 @@ def test_burndown_monthly(self): self.assertIn("+", out) self.assertIn("X", out) + def test_burndown_annual(self): + """Ensure burndown.annual generates a chart""" + code, out, err = self.t("burndown.annual") + self.assertIn("Annual Burndown", out) + self.assertIn(".", out) + self.assertIn("+", out) + self.assertIn("X", out) + if __name__ == "__main__": from simpletap import TAPTestRunner diff --git a/test/commands.test.py b/test/commands.test.py index acf91a68e..7f4de0bbd 100755 --- a/test/commands.test.py +++ b/test/commands.test.py @@ -80,6 +80,8 @@ def test_command_dna_color(self): "Recur", "Ctxt", "Filt", + "", + "", "Most details of", ] col_regex = r"\s*(\x1b\[0m\x1b\[48;5;234m)? (\x1b\[0m\x1b\[48;5;234m)?\s*".join(