From c4a5a75fd669bfcc0961dfa43c6c29781fb8dbbf Mon Sep 17 00:00:00 2001 From: Paul Beckingham Date: Mon, 22 Nov 2010 01:47:10 -0500 Subject: [PATCH] Burndown Chart - Infrastructure rewritten with the following benefits: - greater performance - reduced memory footprint - generalized periods (daily, weekly, monthly) - Currently graphs do not render, daily chart is still the old implementation --- src/burndown.cpp | 742 +++++++++++++++++++++++++++++++++++++++++++++-- src/history.cpp | 95 ++---- 2 files changed, 738 insertions(+), 99 deletions(-) diff --git a/src/burndown.cpp b/src/burndown.cpp index 7b36a7686..16a1d21a7 100644 --- a/src/burndown.cpp +++ b/src/burndown.cpp @@ -26,40 +26,678 @@ //////////////////////////////////////////////////////////////////////////////// #include // TODO Remove -//#include #include -//#include #include -//#include -//#include -//#include -//#include -//#include -//#include #include -#include "Context.h" -//#include "Directory.h" -//#include "File.h" -#include "Date.h" -//#include "Duration.h" -//#include "Table.h" -#include "text.h" -#include "util.h" -#include "main.h" - -//#ifdef HAVE_LIBNCURSES -//#include -//#endif +#include +#include +#include +#include +#include extern Context context; // Helper macro. #define LOC(y,x) (((y) * (width + 1)) + (x)) +//////////////////////////////////////////////////////////////////////////////// +class Bar +{ +public: + Bar (); + Bar (const Bar&); + Bar& operator= (const Bar&); + ~Bar (); + +public: + int offset; // from left of chart + std::string major; // x-axis label, major (year/-/month) + std::string minor; // x-axis label, minor (month/week/day) + int pending; // Number of pending task in period + int started; // Number of started task in period + int done; // Number of done task in period + int added; // Number added in period + int removed; // Number removed in period +}; + +//////////////////////////////////////////////////////////////////////////////// +Bar::Bar () +: offset (0) +, major ("") +, minor ("") +, pending (0) +, started (0) +, done (0) +, added (0) +, removed (0) +{ +} + +//////////////////////////////////////////////////////////////////////////////// +Bar::Bar (const Bar& other) +{ + *this = other; +} + +//////////////////////////////////////////////////////////////////////////////// +Bar& Bar::operator= (const Bar& other) +{ + if (this != &other) + { + offset = other.offset; + major = other.major; + minor = other.minor; + pending = other.pending; + started = other.started; + done = other.done; + added = other.added; + removed = other.removed; + } + + return *this; +} + +//////////////////////////////////////////////////////////////////////////////// +Bar::~Bar () +{ +} + +//////////////////////////////////////////////////////////////////////////////// +class Chart +{ +public: + Chart (char); + Chart (const Chart&); + Chart& operator= (const Chart&); + ~Chart (); + + void scan (std::vector &); + std::string render (); + +private: + void generateBars (); + void optimizeGrid (); + Date quantize (const Date&); + + Date increment (const Date&); + Date decrement (const Date&); + void maxima (); + +public: + int width; + int height; + int graph_width; + int graph_height; + int max_value; + int max_label; + std::vector labels; + int estimated_bars; + int actual_bars; + std::map bars; + Date earliest; + char period; // D, W, M. + std::string grid; + + float find_rate; + float fix_rate; + std::string completion; +}; + +//////////////////////////////////////////////////////////////////////////////// +Chart::Chart (char type) +{ + // How much space is there to render in? This chart will occupy the + // maximum space, and the width drives various other parameters. + width = context.getWidth (); + height = context.getHeight () - 1; // Allow for new line with prompt. + std::cout << "# width " << width << "\n"; + std::cout << "# height " << height << "\n"; + + max_value = 0; + std::cout << "# max_value " << max_value << "\n"; + max_label = 1; + std::cout << "# max_label " << max_label << "\n"; + + graph_height = height - 7; + std::cout << "# graph_height " << graph_height << "\n"; + graph_width = width - max_label - 14; + std::cout << "# graph_width " << graph_width << "\n"; + + // Estimate how many 'bars' can be dsplayed. This will help subset a + // potentially enormous data set. + estimated_bars = (width - 1 - 14) / 3; + std::cout << "# estimated_bars " << estimated_bars << "\n"; + + actual_bars = 0; + std::cout << "# actual_bars " << actual_bars << "\n"; + + period = type; + std::cout << "# period " << period << "\n"; + + // Rates are calculated last. + find_rate = 0.0; + fix_rate = 0.0; +} + +//////////////////////////////////////////////////////////////////////////////// +Chart::Chart (const Chart& other) +{ + *this = other; +} + +//////////////////////////////////////////////////////////////////////////////// +Chart& Chart::operator= (const Chart& other) +{ + if (this != &other) + { + width = other.width; + height = other.height; + graph_width = other.graph_width; + graph_height = other.graph_height; + max_value = other. max_value; + max_label = other.max_label; + labels = other.labels; + estimated_bars = other.estimated_bars; + actual_bars = other.actual_bars; + bars = other.bars; + earliest = other.earliest; + period = other.period; + grid = other.grid; + find_rate = other.find_rate; + fix_rate = other.fix_rate; + completion = other.completion; + } + + return *this; +} + +//////////////////////////////////////////////////////////////////////////////// +Chart::~Chart () +{ +} + +//////////////////////////////////////////////////////////////////////////////// +void Chart::scan (std::vector & tasks) +{ + generateBars (); + + std::cout << "# loaded " << tasks.size () << " tasks\n"; + + // Not quantized, so that "while (xxx < now)" is inclusive. + Date now; + + time_t epoch; + foreach (task, tasks) + { + // The entry date is when the counting starts. + Date from = quantize (Date (task->get ("entry"))); + epoch = from.toEpoch (); + + if (bars.find (epoch) != bars.end ()) + ++bars[epoch].added; + + // e--> e--s--> + // ppp> pppsss> + Task::status status = task->getStatus (); + if (status == Task::pending || + status == Task::waiting) + { + if (task->has ("start")) + { + Date start = quantize (Date (task->get ("start"))); + while (from < start) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].pending; + from = increment (from); + } + + while (from < now) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].started; + from = increment (from); + } + } + else + { + while (from < now) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].pending; + from = increment (from); + } + } + } + + // e--C e--s--C + // pppd> pppsssd> + else if (status == Task::completed) + { + // Truncate history so it starts at 'earliest' for completed tasks. + Date end = quantize (Date (task->get ("end"))); + epoch = end.toEpoch (); + + if (bars.find (epoch) != bars.end ()) + ++bars[epoch].removed; + + if (end < earliest) + { + epoch = earliest.toEpoch (); + if (bars.find (epoch) != bars.end ()) + ++bars[epoch].done; + continue; + } + + if (task->has ("start")) + { + Date start = quantize (Date (task->get ("start"))); + while (from < start) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].pending; + from = increment (from); + } + + while (from < end) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].started; + from = increment (from); + } + + while (from < now) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].done; + from = increment (from); + } + } + else + { + Date end = quantize (Date (task->get ("end"))); + while (from < end) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].pending; + from = increment (from); + } + + while (from < now) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].done; + from = increment (from); + } + } + } + + // e--D e--s--D + // ppp pppsss + else if (status == Task::deleted) + { + // Skip old deleted tasks. + Date end = quantize (Date (task->get ("end"))); + epoch = end.toEpoch (); + if (bars.find (epoch) != bars.end ()) + ++bars[epoch].removed; + + if (end < earliest) + continue; + + if (task->has ("start")) + { + Date start = quantize (Date (task->get ("start"))); + while (from < start) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].pending; + from = increment (from); + } + + while (from < end) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].started; + from = increment (from); + } + } + else + { + Date end = quantize (Date (task->get ("end"))); + while (from < end) + { + epoch = from.toEpoch (); + if (bars.find (epoch) != bars.end ()) ++bars[epoch].pending; + from = increment (from); + } + } + } + } + + // Size the data. + maxima (); +} + +//////////////////////////////////////////////////////////////////////////////// +// Graph should render like this: +// +---------------------------------------------------------------------+ +// | | +// | 20 | | +// | | dd dd dd dd dd dd dd dd | +// | | dd dd dd dd dd dd dd dd dd dd dd dd dd dd | +// | | pp pp ss ss ss ss ss ss ss ss ss dd dd dd dd dd dd dd Done | +// | 10 | pp pp pp pp pp pp ss ss ss ss ss ss dd dd dd dd dd ss Started| +// | | pp pp pp pp pp pp pp pp pp pp pp ss ss ss ss dd dd pp Pending| +// | | pp pp pp pp pp pp pp pp pp pp pp pp pp pp pp ss dd | +// | | pp pp pp pp pp pp pp pp pp pp pp pp pp pp pp pp pp | +// | 0 +---------------------------------------------------- | +// | 21 22 23 24 25 26 27 28 29 30 31 01 02 03 04 05 06 | +// | July August | +// | | +// | Find rate 1.7/d Estimated completion 8/12/2010 | +// | Fix rate 1.3/d | +// +---------------------------------------------------------------------+ +std::string Chart::render () +{ + if (graph_height < 5 || // a 4-line graph is essentially unreadable. + graph_width < 2) // A single-bar graph is useless. + { + return "Terminal window too small to draw a graph.\n"; + } + + // Create a grid, folded into a string. + // TODO Upgrade grid to a vector of strings, for simpler optimization. + grid = ""; + for (int i = 0; i < height; ++i) + grid += std::string (width, ' ') + "\n"; + + // Legend. + grid.replace (LOC (graph_height / 2 - 1, width - 10), 10, "dd Done "); + grid.replace (LOC (graph_height / 2, width - 10), 10, "ss Started"); + grid.replace (LOC (graph_height / 2 + 1, width - 10), 10, "pp Pending"); + + // Draw y-axis. + for (int i = 0; i < graph_height; ++i) + grid.replace (LOC (i + 1, max_label + 1), 1, "|"); + + // TODO Draw y-axis labels. +// char label [12]; +// sprintf (label, "%*d", max_label, labels[2]); +// grid.replace (LOC (1, max_label - strlen (label)), strlen (label), label); +// sprintf (label, "%*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, '-')); + + // TODO Draw x-axis labels. + + // Draw rates. + char rate[12]; + sprintf (rate, "%.1f", find_rate); + grid.replace (LOC (height - 2, max_label + 3), 13 + strlen (rate), std::string ("Find rate: ") + rate + "/d"); + + sprintf (rate, "%.1f", fix_rate); + grid.replace (LOC (height - 1, max_label + 3), 13 + strlen (rate), std::string ("Fix rate: ") + rate + "/d"); + + // Draw completion date. + if (completion.length ()) + grid.replace (LOC (height - 2, max_label + 27), 22 + completion.length (), "Estimated completion: " + completion); + + optimizeGrid (); + + // Colorize the grid. + Color color_pending (context.config.get ("color.burndown.pending")); + Color color_done (context.config.get ("color.burndown.done")); + Color color_started (context.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 (" ")); + + return grid; +} + +//////////////////////////////////////////////////////////////////////////////// +// grid =~ /\s+$//g +void Chart::optimizeGrid () +{ + std::string::size_type ws; + while ((ws = grid.find (" \n")) != std::string::npos) + { + std::string::size_type non_ws = ws; + while (grid[non_ws] == ' ') + --non_ws; + +// std::cout << "# WS at EOL " << non_ws + 1 << "-" << ws << "\n"; + grid.replace (non_ws + 1, ws - non_ws + 1, "\n"); + } +} + +//////////////////////////////////////////////////////////////////////////////// +Date Chart::quantize (const Date& input) +{ + if (period == 'D') return input.startOfDay (); + if (period == 'W') return input.startOfWeek (); + if (period == 'M') return input.startOfMonth (); + + return input; +} + +//////////////////////////////////////////////////////////////////////////////// +Date Chart::increment (const Date& input) +{ + // Move to the next period. + int d = input.day (); + int m = input.month (); + int y = input.year (); + + int days; + + switch (period) + { + case 'D': + if (++d > Date::daysInMonth (m, y)) + { + d = 1; + + if (++m == 13) + { + m = 1; + ++y; + } + } + break; + + case 'W': + d += 7; + days = Date::daysInMonth (m, y); + if (d > days) + { + d -= days; + + if (++m == 13) + { + m = 1; + ++y; + } + } + break; + + case 'M': + d = 1; + if (++m == 13) + { + m = 1; + ++y; + } + break; + } + + return Date (m, d, y, 0, 0, 0); +} + +//////////////////////////////////////////////////////////////////////////////// +Date Chart::decrement (const Date& input) +{ + // Move to the previous period. + int d = input.day (); + int m = input.month (); + int y = input.year (); + + switch (period) + { + case 'D': + if (--d == 0) + { + if (--m == 0) + { + m = 12; + --y; + } + + d = Date::daysInMonth (m, y); + } + break; + + case 'W': + d -= 7; + if (d < 1) + { + if (--m == 0) + { + m = 12; + y--; + } + + d += Date::daysInMonth (m, y); + } + break; + + case 'M': + d = 1; + if (--m == 0) + { + m = 12; + --y; + } + break; + } + + return Date (m, d, y, 0, 0, 0); +} + +//////////////////////////////////////////////////////////////////////////////// +// Do 'bars[epoch] = Bar' for every bar that may appear on a chart. +void Chart::generateBars () +{ + Bar bar; + + // Determine the last bar date. + Date cursor; + switch (period) + { + case 'D': cursor = Date ().startOfDay (); break; + case 'W': cursor = Date ().startOfWeek (); break; + case 'M': cursor = Date ().startOfMonth (); break; + } + + // Iterate and determine all the other bar dates. + char str[12]; + for (int i = 0; i < estimated_bars; ++i) + { + // Create the major and minor labels. + switch (period) + { + case 'D': // month/day + { + std::string month = Date::monthName (cursor.month ()); + bar.major = month.substr (0, 3); + + sprintf (str, "%02d", cursor.day ()); + bar.minor = str; + } + break; + + case 'W': // year/week + sprintf (str, "%d", cursor.year ()); + bar.major = str; + + sprintf (str, "%02d", cursor.weekOfYear (0)); + bar.minor = str; + break; + + case 'M': // year/month + sprintf (str, "%d", cursor.year ()); + bar.major = str; + + sprintf (str, "%02d", cursor.month ()); + bar.minor = str; + break; + } + + bar.offset = i; + bars[cursor.toEpoch ()] = bar; + + // Record the earliest date, for use as a cutoff when scanning data. + earliest = cursor; + + // Move to the previous period. + cursor = decrement (cursor); + } + + std::cout << "# Bar count " << bars.size () << "\n"; + std::cout << "# earliest " << earliest.toString ("YMD") << "\n"; +} + +//////////////////////////////////////////////////////////////////////////////// +void Chart::maxima () +{ + max_value = 0; + max_label = 1; + + std::map ::iterator it; + for (it = bars.begin (); it != bars.end (); it++) + { + // Determine max_label. + int total = it->second.pending + + it->second.started + + it->second.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; + } + + // How many bars can be shown? + actual_bars = (width - max_label - 14) / 3; + graph_width = width - max_label - 14; + + std::cout << "# max_value " << max_value << "\n"; + std::cout << "# max_label " << max_label << "\n"; + std::cout << "# actual_bars " << actual_bars << "\n"; + std::cout << "# graph_width " << graph_width << "\n"; +} + //////////////////////////////////////////////////////////////////////////////// // Given the vertical chart area size (height), the largest value (value), // populate a vector of labels for the y axis. +// TODO Make this a member of Chart. void calculateYAxis (std::vector & labels, int height, int value) { /* @@ -613,6 +1251,37 @@ int handleReportBurndownWeekly (std::string& outs) { int rc = 0; + if (context.hooks.trigger ("pre-burndown-command")) + { + // Scan the pending tasks, applying any filter. + std::vector tasks; + context.tdb.lock (context.config.getBoolean ("locking")); + handleRecurrence (); + context.tdb.load (tasks, context.filter); + context.tdb.commit (); + context.tdb.unlock (); + + // Create a chart, scan the tasks, then render. + Chart chart ('W'); + chart.scan (tasks); + std::map ::iterator it; + for (it = chart.bars.begin (); it != chart.bars.end (); ++it) + std::cout << "# bar " << Date (it->first).toString ("YMD") + << " offset=" << it->second.offset + << " major=" << it->second.major + << " minor=" << it->second.minor + << " pending=" << it->second.pending + << " started=" << it->second.started + << " done=" << it->second.done + << " added=" << it->second.added + << " removed=" << it->second.removed + << "\n"; + + outs = chart.render (); + + context.hooks.trigger ("post-burndown-command"); + } + return rc; } @@ -621,6 +1290,37 @@ int handleReportBurndownMonthly (std::string& outs) { int rc = 0; + if (context.hooks.trigger ("pre-burndown-command")) + { + // Scan the pending tasks, applying any filter. + std::vector tasks; + context.tdb.lock (context.config.getBoolean ("locking")); + handleRecurrence (); + context.tdb.load (tasks, context.filter); + context.tdb.commit (); + context.tdb.unlock (); + + // Create a chart, scan the tasks, then render. + Chart chart ('M'); + chart.scan (tasks); + std::map ::iterator it; + for (it = chart.bars.begin (); it != chart.bars.end (); ++it) + std::cout << "# bar " << Date (it->first).toString ("YMD") + << " offset=" << it->second.offset + << " major=" << it->second.major + << " minor=" << it->second.minor + << " pending=" << it->second.pending + << " started=" << it->second.started + << " done=" << it->second.done + << " added=" << it->second.added + << " removed=" << it->second.removed + << "\n"; + + outs = chart.render (); + + context.hooks.trigger ("post-burndown-command"); + } + return rc; } diff --git a/src/history.cpp b/src/history.cpp index 9f15516ef..e0506da57 100644 --- a/src/history.cpp +++ b/src/history.cpp @@ -25,30 +25,13 @@ // //////////////////////////////////////////////////////////////////////////////// -//#include -//#include #include -//#include -//#include -//#include -//#include -//#include -//#include -//#include -#include "Context.h" -//#include "Directory.h" -//#include "File.h" -//#include "Date.h" -//#include "Duration.h" -#include "Table.h" -#include "text.h" -#include "util.h" -#include "main.h" - -//#ifdef HAVE_LIBNCURSES -//#include -//#endif +#include +#include +#include +#include +#include extern Context context; @@ -84,21 +67,14 @@ int handleReportHistoryMonthly (std::string& outs) groups[epoch] = 0; // Every task has an entry date. - if (addedGroup.find (epoch) != addedGroup.end ()) - addedGroup[epoch] = addedGroup[epoch] + 1; - else - addedGroup[epoch] = 1; + ++addedGroup[epoch]; // All deleted tasks have an end date. if (task->getStatus () == Task::deleted) { epoch = end.startOfMonth ().toEpoch (); groups[epoch] = 0; - - if (deletedGroup.find (epoch) != deletedGroup.end ()) - deletedGroup[epoch] = deletedGroup[epoch] + 1; - else - deletedGroup[epoch] = 1; + ++deletedGroup[epoch]; } // All completed tasks have an end date. @@ -106,11 +82,7 @@ int handleReportHistoryMonthly (std::string& outs) { epoch = end.startOfMonth ().toEpoch (); groups[epoch] = 0; - - if (completedGroup.find (epoch) != completedGroup.end ()) - completedGroup[epoch] = completedGroup[epoch] + 1; - else - completedGroup[epoch] = 1; + ++completedGroup[epoch]; } } @@ -257,21 +229,14 @@ int handleReportHistoryAnnual (std::string& outs) groups[epoch] = 0; // Every task has an entry date. - if (addedGroup.find (epoch) != addedGroup.end ()) - addedGroup[epoch] = addedGroup[epoch] + 1; - else - addedGroup[epoch] = 1; + ++addedGroup[epoch]; // All deleted tasks have an end date. if (task->getStatus () == Task::deleted) { epoch = end.startOfYear ().toEpoch (); groups[epoch] = 0; - - if (deletedGroup.find (epoch) != deletedGroup.end ()) - deletedGroup[epoch] = deletedGroup[epoch] + 1; - else - deletedGroup[epoch] = 1; + ++deletedGroup[epoch]; } // All completed tasks have an end date. @@ -279,11 +244,7 @@ int handleReportHistoryAnnual (std::string& outs) { epoch = end.startOfYear ().toEpoch (); groups[epoch] = 0; - - if (completedGroup.find (epoch) != completedGroup.end ()) - completedGroup[epoch] = completedGroup[epoch] + 1; - else - completedGroup[epoch] = 1; + ++completedGroup[epoch]; } } @@ -427,21 +388,14 @@ int handleReportGHistoryMonthly (std::string& outs) groups[epoch] = 0; // Every task has an entry date. - if (addedGroup.find (epoch) != addedGroup.end ()) - addedGroup[epoch] = addedGroup[epoch] + 1; - else - addedGroup[epoch] = 1; + ++addedGroup[epoch]; // All deleted tasks have an end date. if (task->getStatus () == Task::deleted) { epoch = end.startOfMonth ().toEpoch (); groups[epoch] = 0; - - if (deletedGroup.find (epoch) != deletedGroup.end ()) - deletedGroup[epoch] = deletedGroup[epoch] + 1; - else - deletedGroup[epoch] = 1; + ++deletedGroup[epoch]; } // All completed tasks have an end date. @@ -449,11 +403,7 @@ int handleReportGHistoryMonthly (std::string& outs) { epoch = end.startOfMonth ().toEpoch (); groups[epoch] = 0; - - if (completedGroup.find (epoch) != completedGroup.end ()) - completedGroup[epoch] = completedGroup[epoch] + 1; - else - completedGroup[epoch] = 1; + ++completedGroup[epoch]; } } @@ -640,21 +590,14 @@ int handleReportGHistoryAnnual (std::string& outs) groups[epoch] = 0; // Every task has an entry date. - if (addedGroup.find (epoch) != addedGroup.end ()) - addedGroup[epoch] = addedGroup[epoch] + 1; - else - addedGroup[epoch] = 1; + ++addedGroup[epoch]; // All deleted tasks have an end date. if (task->getStatus () == Task::deleted) { epoch = end.startOfYear ().toEpoch (); groups[epoch] = 0; - - if (deletedGroup.find (epoch) != deletedGroup.end ()) - deletedGroup[epoch] = deletedGroup[epoch] + 1; - else - deletedGroup[epoch] = 1; + ++deletedGroup[epoch]; } // All completed tasks have an end date. @@ -662,11 +605,7 @@ int handleReportGHistoryAnnual (std::string& outs) { epoch = end.startOfYear ().toEpoch (); groups[epoch] = 0; - - if (completedGroup.find (epoch) != completedGroup.end ()) - completedGroup[epoch] = completedGroup[epoch] + 1; - else - completedGroup[epoch] = 1; + ++completedGroup[epoch]; } }