From 83caba39071e2ece655dd6220a5b1f5a84d6ed5d Mon Sep 17 00:00:00 2001 From: ashprice Date: Thu, 25 Jun 2026 17:08:15 +0100 Subject: [PATCH 1/3] Burndown rewrite (initial commit/proof of concept) Signed-off-by: ashprice --- src/commands/CmdBurndown.cpp | 441 +++++++++++++++++++++-------------- 1 file changed, 263 insertions(+), 178 deletions(-) diff --git a/src/commands/CmdBurndown.cpp b/src/commands/CmdBurndown.cpp index b0045cab8..9226e2972 100644 --- a/src/commands/CmdBurndown.cpp +++ b/src/commands/CmdBurndown.cpp @@ -41,6 +41,7 @@ #include #include #include +#include // Helper macro. #define LOC(y, x) (((y) * (_width + 1)) + (x)) @@ -114,6 +115,9 @@ Bar& Bar::operator=(const Bar& other) { // 30 31 01 02 03 04 05 06 07 08 09 10 // Oct Nov // + +struct TaskEpochRange; + class Chart { public: Chart(char); @@ -121,14 +125,16 @@ class Chart { Chart& operator=(const Chart&); // Unimplemented ~Chart() = default; - void scan(std::vector&); - void scanForPeak(std::vector&); + void accumulateTasks(const std::vector&, const std::vector&); + void generateBars(); + void finalize(); + void buildEpochRange(time_t, time_t); std::string render(); + static Datetime quantize(const Datetime&, char); + private: - void generateBars(); void optimizeGrid(); - Datetime quantize(const Datetime&, char); Datetime increment(const Datetime&, char); Datetime decrement(const Datetime&, char); @@ -139,25 +145,29 @@ class Chart { 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 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 + std::vector _epochs{}; // All the epochs. + std::vector _pending_counts{}; // Pending counts by epoch. + std::unordered_map _epoch_index{}; // A map from the bar epoch + // to its index in _epochs/_pending_counts + time_t _peak_epoch{}; // 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 }; //////////////////////////////////////////////////////////////////////////////// @@ -179,130 +189,152 @@ Chart::Chart(char 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'); - } +// Result of findTaskEpochRange. This includes the broad range of bars the task +// can touch, as well as the quantized epochs (so we only have to quantize once.) +struct TaskEpochRange { + time_t first_epoch; // the leftmost bar this task touches + time_t last_epoch; // the rightmost bar this task touches + time_t entry_epoch; // the quantized date at which the task was added + time_t end_epoch; // the quantized date at which the task was closed + bool has_end; +}; + +///////////////////////////////////////////////////////////////////////////////// +// Compute the epoch range and the quantized epochs for a given task. This is +// used by scan epochs{} to size the global range, and by accumulateTasks() to +// skip tasks which don't contribute to the chart. +// Per-task counts are computed in accumulateTasks() from their entry_epoch and +// end_epoch. last_epoch is a different thing, because in cumulative mode, it +// must extend until today - hence the last epoch and end epoch are different - +// end is the date at which the task was completed. +static TaskEpochRange findTaskEpochRange(const Task& task, Datetime now_quantized, char period, + bool cumulative) { + time_t entry_epoch = Chart::quantize(Datetime(task.get_date("entry")), period).toEpoch(); + Task::status status = task.getStatus(); + + if (status == Task::pending || status == Task::waiting) { + // Pending tasks span entry-end/today. The today period is inclusive of the + // unquantized period, because 'today' will not be over at the point at which + // the report is ran. + bool has_end = task.has("end"); + time_t last = has_end ? Chart::quantize(Datetime(task.get_date("end")), period).toEpoch() + : now_quantized.toEpoch(); + time_t end_epoch = has_end ? last : 0; + return {entry_epoch, last, entry_epoch, end_epoch, has_end}; } - // Find the peak and peak date. - for (auto& count : pending) { - if (count.second > _peak_count) { - _peak_count = count.second; - _peak_epoch = count.first; - } + if (status == Task::completed) { + // The range for completed tasks also cover entry-end/today. We use min for + // the leftmost bar, and max for the rightmost. In cumulative mode, the + // rightmost epoch that is relevant is today, so this is included. In + // non-cumulative mode, the rightmost relevant bar is end. + // We also cover the case where there has been some kind of bug where + // the entry date is later than the end date. In this case, the leftmost + // relevant bar is the end date, and the rightmost is today in cumulative + // mode and entry in non-cumulative mode. + time_t end_epoch = Chart::quantize(Datetime(task.get_date("end")), period).toEpoch(); + time_t leftmost = std::min(entry_epoch, end_epoch); + if (cumulative) + return {leftmost, std::max(end_epoch, now_quantized.toEpoch()), entry_epoch, end_epoch, true}; + return {leftmost, std::max(end_epoch, entry_epoch), entry_epoch, end_epoch, true}; } + + // Deleted/recurring tasks are irrelevant. + return {1, 0, entry_epoch, 0, false}; } //////////////////////////////////////////////////////////////////////////////// -void Chart::scan(std::vector& tasks) { - generateBars(); - - // Not quantized, so that "while (xxx < now)" is inclusive. +void Chart::accumulateTasks(const std::vector& tasks, + const std::vector& ranges) { Datetime now; - - time_t epoch; + Datetime now_quantized = quantize(now, _period); auto& config = Context::getContext().config; - bool cumulative; - if (config.has("burndown.cumulative")) { - cumulative = config.getBoolean("burndown.cumulative"); - } else { - cumulative = true; - } + bool cumulative = + config.has("burndown.cumulative") ? config.getBoolean("burndown.cumulative") : true; + + for (size_t i = 0; i < tasks.size(); ++i) { + const auto& r = ranges[i]; + const auto& task = tasks[i]; + + if (r.first_epoch > r.last_epoch) continue; - for (auto& task : tasks) { - // The entry date is when the counting starts. - Datetime from = quantize(Datetime(task.get_date("entry")), _period); - epoch = from.toEpoch(); + if (!r.has_end) ++_current_count; - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._added; + // This is the marker for entry dates. + auto added_bar = _bars.find(r.entry_epoch); + if (added_bar != _bars.end()) ++added_bar->second._added; - // 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); - } + // Looks up the task's entry/last epoch in the bar array. _epochs_index + // then maps this to the positions in _epochs/_pending_counts. + auto entry_idx = _epoch_index.find(r.entry_epoch); + if (entry_idx == _epoch_index.end()) continue; + auto last_idx = _epoch_index.find(r.last_epoch); + if (last_idx == _epoch_index.end()) continue; + + // Count the task as pending for every bar through entry-last epoch, + // inclusive of the incomplete 'today' period. + for (auto i = entry_idx->second, last = last_idx->second; i <= last; ++i) { + ++_pending_counts[i]; + _bars[_epochs[i]]._pending++; + } - while (from < now) { - epoch = from.toEpoch(); - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._started; - from = increment(from, _period); + // If the task has a start attribute, bars from that point through last_epoch + // are changed from pending to started. + if (task.has("start")) { + time_t start_epoch = quantize(Datetime(task.get_date("start")), _period).toEpoch(); + auto start_idx = _epoch_index.find(start_epoch); + if (start_idx != _epoch_index.end()) { + for (auto i = start_idx->second, last = last_idx->second; i <= last; ++i) { + // Removes it from the pending count, and counts it as started instead. + --_pending_counts[i]; + _bars[_epochs[i]]._pending--; + ++_bars[_epochs[i]]._started; + } } - } else { - while (from < now) { - epoch = from.toEpoch(); - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._pending; - from = increment(from, _period); + } + } else if (status == Task::completed) { + auto entry_idx = _epoch_index.find(r.entry_epoch); + auto end_idx = _epoch_index.find(r.end_epoch); + + // We want to exclude the completion day as pending, otherwise it would + // double the count. + if (entry_idx != _epoch_index.end() && end_idx != _epoch_index.end()) { + for (auto i = entry_idx->second, end = end_idx->second; i < end; ++i) { + ++_pending_counts[i]; + _bars[_epochs[i]]._pending++; } } - } - - // 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(); - 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); - } + // We remove tasks at the end_epoch. + auto removed_bar = _bars.find(r.end_epoch); + if (removed_bar != _bars.end()) ++removed_bar->second._removed; 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; + // Just to restate: in cumulative mode, we have to extend to today. + auto now_idx = _epoch_index.find(now_quantized.toEpoch()); + if (now_idx != _epoch_index.end()) { + time_t done_start_epoch = std::max(r.entry_epoch, r.end_epoch); + auto done_start_idx = _epoch_index.find(done_start_epoch); + if (done_start_idx != _epoch_index.end()) { + for (auto i = done_start_idx->second, now = now_idx->second; i <= now; ++i) { + ++_bars[_epochs[i]]._done; + } + } } - } - else { - epoch = from.toEpoch(); - if (_bars.find(epoch) != _bars.end()) ++_bars[epoch]._done; + // Carry the count over if it's outside the visible chart. + if (end_idx == _epoch_index.end()) ++_carryover_done; + } else { + // Finally: in non-cumulative mode each done task gets only one point + // on the bar. + 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(); } //////////////////////////////////////////////////////////////////////////////// @@ -372,15 +404,10 @@ std::string Chart::render() { _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()); + // Draw x-axis labels. _bars is a map where the keys are each epochs time_t, ie. we have + // already sorted. std::string _major_label; - 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) { _grid.replace(LOC(_height - 5, _max_label + 3 + ((_actual_bars - bar._offset - 1) * 3)), @@ -395,9 +422,7 @@ std::string Chart::render() { } // 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]; @@ -490,6 +515,27 @@ Datetime Chart::quantize(const Datetime& input, char period) { return input; } +///////////////////////////////////////////////////////////////////////////////// +// We pre-compute every bar epoch between the earliest and last epoch, one period +// at a time. We build three arrays indexed by their linear position. +void Chart::buildEpochRange(time_t earliest_epoch, time_t latest_epoch) { + _epochs.clear(); + _pending_counts.clear(); + _epoch_index.clear(); + + Datetime cursor(earliest_epoch); + Datetime end(latest_epoch); + while (cursor <= end) { + time_t ep = cursor.toEpoch(); + _epoch_index[ep] = _epochs.size(); // _epochs_index is a reverse lookup - + // given an epoch, give us its position. + // Used by accumulateTasks(). + _epochs.push_back(ep); // These are the epochs themselves in ascending order. + _pending_counts.push_back(0); // This tracks the pending count per epoch. + cursor = increment(cursor, _period); + } +} + //////////////////////////////////////////////////////////////////////////////// Datetime Chart::increment(const Datetime& input, char period) { // Move to the next period. @@ -531,6 +577,9 @@ Datetime Chart::increment(const Datetime& input, char period) { ++y; } break; + + default: + break; } return Datetime(y, m, d, 0, 0, 0); @@ -641,6 +690,23 @@ void Chart::generateBars() { } //////////////////////////////////////////////////////////////////////////////// +// Once period data has been accumulated, we want to find the peak count. +// We use this to size the bar for rendering. +void Chart::finalize() { + _peak_count = 0; + _peak_epoch = 0; + for (size_t i = 0; i < _epochs.size(); ++i) { + if (_pending_counts[i] > _peak_count) { + _peak_count = _pending_counts[i]; + _peak_epoch = _epochs[i]; + } + } + + maxima(); +} + +/////////////////////////////////////////////////////////////////////////////// + void Chart::maxima() { _max_value = 0; _max_label = 1; @@ -758,6 +824,67 @@ unsigned Chart::burndown_size(unsigned ntasks) { return std::numeric_limits::max(); } +///////////////////////////////////////////////////////////////////////////////// +// This combines the work from the old CmdBurndown functions into one main function. +// period sets the chart granularity. output is the rendered chart. +static int runBurndown(char period, std::string& output) { + // First we build the chart's time window. generateBars() populates _bars with + // epoch keys and labels. buildEpochRange() deals with tasks outside of the + // chart window. + Chart chart(period); + chart.generateBars(); + + // Detects if the user is using filters. + bool has_filter = false; + for (const auto& a : Context::getContext().cli2._args) + if (a.hasTag("FILTER")) { + has_filter = true; + break; + } + + // We determine the global window across all tasks that can contribute to the + // chart. + Datetime now_quantized = Chart::quantize(Datetime(), period); + time_t earliest = now_quantized.toEpoch(); + time_t latest = 0; + bool cumulative; + auto& cfg = Context::getContext().config; + cumulative = cfg.has("burndown.cumulative") ? cfg.getBoolean("burndown.cumulative") : true; + + auto scan_epochs = [&](const std::vector& tasks, std::vector& ranges) { + for (const auto& task : tasks) { + auto r = findTaskEpochRange(task, now_quantized, period, cumulative); + ranges.push_back(r); + if (r.first_epoch > r.last_epoch) continue; + if (r.first_epoch < earliest) earliest = r.first_epoch; + if (r.last_epoch > latest) latest = r.last_epoch; + } + }; + + if (has_filter) { + Filter filter; + std::vector filtered; + filter.subset(filtered); + std::vector ranges; + scan_epochs(filtered, ranges); + chart.buildEpochRange(earliest, latest); + chart.accumulateTasks(filtered, ranges); + } else { + const auto& pending = Context::getContext().tdb2.pending_tasks(); + const auto& completed = Context::getContext().tdb2.completed_tasks(); + std::vector pending_ranges, completed_ranges; + scan_epochs(pending, pending_ranges); + scan_epochs(completed, completed_ranges); + chart.buildEpochRange(earliest, latest); + chart.accumulateTasks(pending, pending_ranges); + chart.accumulateTasks(completed, completed_ranges); + } + + chart.finalize(); + output = chart.render(); + return 0; +} + //////////////////////////////////////////////////////////////////////////////// CmdBurndownMonthly::CmdBurndownMonthly() { _keyword = "burndown.monthly"; @@ -775,21 +902,7 @@ CmdBurndownMonthly::CmdBurndownMonthly() { } //////////////////////////////////////////////////////////////////////////////// -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; -} +int CmdBurndownMonthly::execute(std::string& output) { return runBurndown('M', output); } //////////////////////////////////////////////////////////////////////////////// CmdBurndownWeekly::CmdBurndownWeekly() { @@ -808,21 +921,7 @@ CmdBurndownWeekly::CmdBurndownWeekly() { } //////////////////////////////////////////////////////////////////////////////// -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; -} +int CmdBurndownWeekly::execute(std::string& output) { return runBurndown('W', output); } //////////////////////////////////////////////////////////////////////////////// CmdBurndownDaily::CmdBurndownDaily() { @@ -841,20 +940,6 @@ CmdBurndownDaily::CmdBurndownDaily() { } //////////////////////////////////////////////////////////////////////////////// -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); - output = chart.render(); - return rc; -} +int CmdBurndownDaily::execute(std::string& output) { return runBurndown('D', output); } //////////////////////////////////////////////////////////////////////////////// From a5532b16a197b7b1560584bf6ea8d03944fde477 Mon Sep 17 00:00:00 2001 From: ashprice Date: Sat, 27 Jun 2026 14:50:35 +0100 Subject: [PATCH 2/3] Burndown rewrite 2.0 - regression fixes, another overhaul Signed-off-by: ashprice Derp. Signed-off-by: ashprice clang-format thinks clang-format knows best. it does not. Signed-off-by: ashprice comment clean up Signed-off-by: ashprice --- src/commands/CmdBurndown.cpp | 852 +++++++++++++++++------------------ 1 file changed, 406 insertions(+), 446 deletions(-) diff --git a/src/commands/CmdBurndown.cpp b/src/commands/CmdBurndown.cpp index 9226e2972..aafb2977a 100644 --- a/src/commands/CmdBurndown.cpp +++ b/src/commands/CmdBurndown.cpp @@ -25,21 +25,19 @@ //////////////////////////////////////////////////////////////////////////////// #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 @@ -50,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 @@ -95,79 +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 accumulateTasks(const std::vector&, const std::vector&); + void accumulateTasks(const std::vector&, const std::vector&, + time_t now_epoch, bool cumulative); void generateBars(); void finalize(); - void buildEpochRange(time_t, time_t); std::string render(); - - static Datetime quantize(const Datetime&, char); + void buildPeakRange(time_t, time_t); private: - void optimizeGrid(); - + 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 - std::vector _epochs{}; // All the epochs. - std::vector _pending_counts{}; // Pending counts by epoch. - std::unordered_map _epoch_index{}; // A map from the bar epoch - // to its index in _epochs/_pending_counts - time_t _peak_epoch{}; // 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 }; //////////////////////////////////////////////////////////////////////////////// @@ -181,155 +155,164 @@ 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; } -//////////////////////////////////////////////////////////////////////////////// -// Result of findTaskEpochRange. This includes the broad range of bars the task -// can touch, as well as the quantized epochs (so we only have to quantize once.) +// - 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; // the leftmost bar this task touches - time_t last_epoch; // the rightmost bar this task touches - time_t entry_epoch; // the quantized date at which the task was added - time_t end_epoch; // the quantized date at which the task was closed - bool has_end; + 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 }; -///////////////////////////////////////////////////////////////////////////////// -// Compute the epoch range and the quantized epochs for a given task. This is -// used by scan epochs{} to size the global range, and by accumulateTasks() to -// skip tasks which don't contribute to the chart. -// Per-task counts are computed in accumulateTasks() from their entry_epoch and -// end_epoch. last_epoch is a different thing, because in cumulative mode, it -// must extend until today - hence the last epoch and end epoch are different - -// end is the date at which the task was completed. -static TaskEpochRange findTaskEpochRange(const Task& task, Datetime now_quantized, char period, - bool cumulative) { - time_t entry_epoch = Chart::quantize(Datetime(task.get_date("entry")), period).toEpoch(); +//////////////////////////////////////////////////////////////////////////////// +// 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(); + } + Task::status status = task.getStatus(); + // Pending/waiting tasks extend to today if they lack an end. if (status == Task::pending || status == Task::waiting) { - // Pending tasks span entry-end/today. The today period is inclusive of the - // unquantized period, because 'today' will not be over at the point at which - // the report is ran. - bool has_end = task.has("end"); - time_t last = has_end ? Chart::quantize(Datetime(task.get_date("end")), period).toEpoch() - : now_quantized.toEpoch(); - time_t end_epoch = has_end ? last : 0; - return {entry_epoch, last, entry_epoch, end_epoch, has_end}; + 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}; } + // Completed tasks extend to end (non-cumulative) or today (cumulative). if (status == Task::completed) { - // The range for completed tasks also cover entry-end/today. We use min for - // the leftmost bar, and max for the rightmost. In cumulative mode, the - // rightmost epoch that is relevant is today, so this is included. In - // non-cumulative mode, the rightmost relevant bar is end. - // We also cover the case where there has been some kind of bug where - // the entry date is later than the end date. In this case, the leftmost - // relevant bar is the end date, and the rightmost is today in cumulative - // mode and entry in non-cumulative mode. - time_t end_epoch = Chart::quantize(Datetime(task.get_date("end")), period).toEpoch(); - time_t leftmost = std::min(entry_epoch, end_epoch); - if (cumulative) - return {leftmost, std::max(end_epoch, now_quantized.toEpoch()), entry_epoch, end_epoch, true}; - return {leftmost, std::max(end_epoch, entry_epoch), entry_epoch, end_epoch, true}; + 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}; } - // Deleted/recurring tasks are irrelevant. - return {1, 0, entry_epoch, 0, false}; + // 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}; } //////////////////////////////////////////////////////////////////////////////// void Chart::accumulateTasks(const std::vector& tasks, - const std::vector& ranges) { - Datetime now; - Datetime now_quantized = quantize(now, _period); - auto& config = Context::getContext().config; - bool cumulative = - config.has("burndown.cumulative") ? config.getBoolean("burndown.cumulative") : true; + 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]; - if (r.first_epoch > r.last_epoch) continue; + // 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; - if (!r.has_end) ++_current_count; + Task::status status = task.getStatus(); - // This is the marker for entry dates. - auto added_bar = _bars.find(r.entry_epoch); - if (added_bar != _bars.end()) ++added_bar->second._added; + // 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; - Task::status status = task.getStatus(); if (status == Task::pending || status == Task::waiting) { - // Looks up the task's entry/last epoch in the bar array. _epochs_index - // then maps this to the positions in _epochs/_pending_counts. - auto entry_idx = _epoch_index.find(r.entry_epoch); - if (entry_idx == _epoch_index.end()) continue; - auto last_idx = _epoch_index.find(r.last_epoch); - if (last_idx == _epoch_index.end()) continue; - - // Count the task as pending for every bar through entry-last epoch, - // inclusive of the incomplete 'today' period. - for (auto i = entry_idx->second, last = last_idx->second; i <= last; ++i) { - ++_pending_counts[i]; - _bars[_epochs[i]]._pending++; - } - - // If the task has a start attribute, bars from that point through last_epoch - // are changed from pending to started. - if (task.has("start")) { - time_t start_epoch = quantize(Datetime(task.get_date("start")), _period).toEpoch(); - auto start_idx = _epoch_index.find(start_epoch); - if (start_idx != _epoch_index.end()) { - for (auto i = start_idx->second, last = last_idx->second; i <= last; ++i) { - // Removes it from the pending count, and counts it as started instead. - --_pending_counts[i]; - _bars[_epochs[i]]._pending--; - ++_bars[_epochs[i]]._started; - } - } + // 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) { - auto entry_idx = _epoch_index.find(r.entry_epoch); - auto end_idx = _epoch_index.find(r.end_epoch); - - // We want to exclude the completion day as pending, otherwise it would - // double the count. - if (entry_idx != _epoch_index.end() && end_idx != _epoch_index.end()) { - for (auto i = entry_idx->second, end = end_idx->second; i < end; ++i) { - ++_pending_counts[i]; - _bars[_epochs[i]]._pending++; - } - } - - // We remove tasks at the end_epoch. - auto removed_bar = _bars.find(r.end_epoch); - if (removed_bar != _bars.end()) ++removed_bar->second._removed; + // 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) { - // Just to restate: in cumulative mode, we have to extend to today. - auto now_idx = _epoch_index.find(now_quantized.toEpoch()); - if (now_idx != _epoch_index.end()) { - time_t done_start_epoch = std::max(r.entry_epoch, r.end_epoch); - auto done_start_idx = _epoch_index.find(done_start_epoch); - if (done_start_idx != _epoch_index.end()) { - for (auto i = done_start_idx->second, now = now_idx->second; i <= now; ++i) { - ++_bars[_epochs[i]]._done; - } - } - } - - // Carry the count over if it's outside the visible chart. - if (end_idx == _epoch_index.end()) ++_carryover_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 { - // Finally: in non-cumulative mode each done task gets only one point - // on the bar. - time_t done_epoch = std::max(r.entry_epoch, r.end_epoch); + // 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; } @@ -371,53 +354,53 @@ 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"; 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, '-')); + 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 map where the keys are each epochs time_t, ie. we have - // already sorted. - std::string _major_label; + // 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; } } @@ -425,25 +408,18 @@ std::string Chart::render() { 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"); } } @@ -453,61 +429,73 @@ 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(); @@ -515,27 +503,6 @@ Datetime Chart::quantize(const Datetime& input, char period) { return input; } -///////////////////////////////////////////////////////////////////////////////// -// We pre-compute every bar epoch between the earliest and last epoch, one period -// at a time. We build three arrays indexed by their linear position. -void Chart::buildEpochRange(time_t earliest_epoch, time_t latest_epoch) { - _epochs.clear(); - _pending_counts.clear(); - _epoch_index.clear(); - - Datetime cursor(earliest_epoch); - Datetime end(latest_epoch); - while (cursor <= end) { - time_t ep = cursor.toEpoch(); - _epoch_index[ep] = _epochs.size(); // _epochs_index is a reverse lookup - - // given an epoch, give us its position. - // Used by accumulateTasks(). - _epochs.push_back(ep); // These are the epochs themselves in ascending order. - _pending_counts.push_back(0); // This tracks the pending count per epoch. - cursor = increment(cursor, _period); - } -} - //////////////////////////////////////////////////////////////////////////////// Datetime Chart::increment(const Datetime& input, char period) { // Move to the next period. @@ -585,6 +552,25 @@ Datetime Chart::increment(const Datetime& input, char period) { 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. @@ -623,31 +609,27 @@ Datetime Chart::decrement(const Datetime& input, char period) { --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. @@ -681,7 +663,8 @@ void Chart::generateBars() { 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. @@ -690,36 +673,42 @@ void Chart::generateBars() { } //////////////////////////////////////////////////////////////////////////////// -// Once period data has been accumulated, we want to find the peak count. -// We use this to size the bar for rendering. +// 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_epoch = 0; - for (size_t i = 0; i < _epochs.size(); ++i) { - if (_pending_counts[i] > _peak_count) { - _peak_count = _pending_counts[i]; - _peak_epoch = _epochs[i]; + _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? @@ -730,14 +719,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}; } //////////////////////////////////////////////////////////////////////////////// @@ -747,7 +734,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()); @@ -755,17 +742,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()); @@ -774,9 +764,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() + ')'; @@ -805,8 +795,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; @@ -824,60 +815,61 @@ unsigned Chart::burndown_size(unsigned ntasks) { return std::numeric_limits::max(); } -///////////////////////////////////////////////////////////////////////////////// -// This combines the work from the old CmdBurndown functions into one main function. -// period sets the chart granularity. output is the rendered chart. +//////////////////////////////////////////////////////////////////////////////// static int runBurndown(char period, std::string& output) { - // First we build the chart's time window. generateBars() populates _bars with - // epoch keys and labels. buildEpochRange() deals with tasks outside of the - // chart window. - Chart chart(period); - chart.generateBars(); - - // Detects if the user is using filters. - bool has_filter = false; - for (const auto& a : Context::getContext().cli2._args) - if (a.hasTag("FILTER")) { - has_filter = true; - break; - } + 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"); }); - // We determine the global window across all tasks that can contribute to the - // chart. - Datetime now_quantized = Chart::quantize(Datetime(), period); - time_t earliest = now_quantized.toEpoch(); - time_t latest = 0; - bool cumulative; + // 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; - cumulative = cfg.has("burndown.cumulative") ? cfg.getBoolean("burndown.cumulative") : true; + bool cumulative = cfg.has("burndown.cumulative") ? cfg.getBoolean("burndown.cumulative") : true; - auto scan_epochs = [&](const std::vector& tasks, std::vector& ranges) { + // 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_quantized, period, cumulative); - ranges.push_back(r); - if (r.first_epoch > r.last_epoch) continue; - if (r.first_epoch < earliest) earliest = r.first_epoch; - if (r.last_epoch > latest) latest = r.last_epoch; + 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); - std::vector ranges; - scan_epochs(filtered, ranges); - chart.buildEpochRange(earliest, latest); - chart.accumulateTasks(filtered, ranges); + 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(); - std::vector pending_ranges, completed_ranges; - scan_epochs(pending, pending_ranges); - scan_epochs(completed, completed_ranges); - chart.buildEpochRange(earliest, latest); - chart.accumulateTasks(pending, pending_ranges); - chart.accumulateTasks(completed, completed_ranges); + 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); } chart.finalize(); @@ -886,60 +878,28 @@ static int runBurndown(char period, std::string& output) { } //////////////////////////////////////////////////////////////////////////////// -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) { return runBurndown('M', output); } - -//////////////////////////////////////////////////////////////////////////////// -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) { return runBurndown('W', output); } - -//////////////////////////////////////////////////////////////////////////////// -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; -} - -//////////////////////////////////////////////////////////////////////////////// -int CmdBurndownDaily::execute(std::string& output) { return runBurndown('D', output); } - -//////////////////////////////////////////////////////////////////////////////// +// 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") + +#undef CMDBURNDOWN_CTOR From cddf3c0b80e934b45f3407f45478ffae1592833a Mon Sep 17 00:00:00 2001 From: ashprice Date: Mon, 29 Jun 2026 09:39:21 +0100 Subject: [PATCH 3/3] Added annual burndown report Signed-off-by: ashprice Typo. Signed-off-by: ashprice --- doc/man/task.1.in | 4 ++++ src/commands/CmdBurndown.cpp | 26 +++++++++++++++++++++++++- src/commands/CmdBurndown.h | 6 ++++++ src/commands/CmdReports.cpp | 1 + src/commands/Command.cpp | 2 ++ test/burndown.test.py | 8 ++++++++ test/commands.test.py | 2 ++ 7 files changed, 48 insertions(+), 1 deletion(-) 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 aafb2977a..7ec55f867 100644 --- a/src/commands/CmdBurndown.cpp +++ b/src/commands/CmdBurndown.cpp @@ -359,7 +359,11 @@ std::string Chart::render() { 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); @@ -499,6 +503,7 @@ 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; } @@ -545,6 +550,12 @@ Datetime Chart::increment(const Datetime& input, char period) { } break; + case 'Y': + d = 1; + m = 1; + ++y; + break; + default: break; } @@ -610,6 +621,12 @@ Datetime Chart::decrement(const Datetime& input, char period) { } break; + case 'Y': + d = 1; + m = 1; + --y; + break; + default: break; } @@ -658,6 +675,12 @@ 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; @@ -901,5 +924,6 @@ static int runBurndown(char period, std::string& 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(