For quite some time now, I've been talking of the "interesting" stuff I want to do with KTM. Well, the time has finally come - over the last two weeks I built three manual Spring-Roo controllers that extract information and present it in meaningful ways to assist people in day-to-day management of their tasks. Two of them are reports that developers here have to prepare on a weekly and monthly basis respectively. The third one is a burndown chart that presents a live view of the project, including drilling down to a single person at any point of time.
Since its very likely that you are here because you are looking for templates to build your manual controller in your Roo generated app, and because I want to keep this post down to a reasonable length, I decided to provide code for the third (and IMO most interesting) controller - the burndown chart. The basic pattern is similar for all three, so once you understand one, its only a matter of extracting the relevant information to present in your report, ie, very application dependent.
For those of you who are interested in KTM (rather than Roo), I will describe briefly the other two reports as well, just so you get an idea of what can be one with KTM. If you are looking for background information on KTM, you will find it here, here and here.
AspectJ Plugin Installation
Roo generates AspectJ code for boilerplate functionality (such as getters and setters, database persistence, finders, etc) that most developers don't want to create and maintain themselves. However, when you are building manual controllers, you want your IDE to find and suggest the AspectJ methods, otherwise it doesn't buy you much. The Spring project provides the STS (Spring Tool Suite) plugin for Eclipse, which does this. I use MyEclipse, however, and STS apparently does not play well with MyEclipse, going by what I read on the MyEclipse forums.
One way (suggested by the Roo team) is to install the AJDT (AspectJ Development Tools plugin) on Eclipse (or other IDE). The messages on the MyEclipse forum aren't very encouraging about that approach either, so after some trepidation, I decided to take the plunge anyway and install AJDT 2.1.0 on top of MyEclipse 8.5/Eclipse 3.5.2 (Galileo) on my laptop. I am happy to report that things seem to work, although slower (but not intolerably so) than it did before AJDT - Roo generated .aj files are now syntax highlighted, and auto-complete and syntax checking works on my Java code with methods from the .aj files. I do have to periodically rebuild my project (Control-B), though, since I am running the app using mvn jetty:run from the command line, and Eclipse and Maven share the same target directory, so files get overwritten.
So, while using Eclipse STS for Roo development is probably the ideal approach, AJDT with MyEclipse works for me as well, which is good, since I hate having to open up different IDEs for different applications.
Generate Roo Artifacts
The first step is to generate the manual controllers and all the associated artifacts using the Roo shell. Here are the relevant bits from my log.roo file:
1 2 3 4 5 6 7 8 9 10 | // declare the controllers
controller class --class com.healthline.ktm.web.StatusReportController
controller class --class com.healthline.ktm.web.CostCenterReportController
controller class --class com.healthline.ktm.web.BurndownChartController
// declare an YorN enum to use for various entities and the reports
enum type --class com.healthline.ktm.domain.YorN
enum constant --name Yes
// Roo wouldn't let me declare a constant "No" since it apparently
// clashes with a SQL reserved word, so I did this manually in the
// generated Enum code.
|
For the rest of this work, I kept the roo shell turned off. So the rest of this stuff is all Java/Spring, and integrating my controller code into the
Adding new JAR dependencies
I needed a few extra JAR files for the manual controllers. Specifically, I planned on using commons-math to do linear regression in the Burndown chart, the JFreeChart library to draw the actual graph, and commons-lang because I am so used to its convenience methods.
I could have used the Roo shell to add these in, but I had already customized the Jetty target in the pom.xml, and I did not want it to get overwritten by Roo, so I just added these dependencies in by hand.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <dependencies>
...
<!-- KTM dependencies -->
<dependency>
<groupId>commons-math</groupId>
<artifactId>commons-math</artifactId>
<version>2.0</version>
</dependency>
<dependency>
<groupId>org.jfree</groupId>
<artifactId>jfreechart</artifactId>
<version>1.0.11</version>
</dependency>
<dependency>
<groupId>org.jfree</groupId>
<artifactId>jcommon</artifactId>
<version>1.0.14</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.5</version>
</dependency>
</dependencies>
|
Controller Structure
The Change Password Controller I described in my previous post just returned a static "Success" page. However, all my report controllers return dynamic information so I needed to change the controller method signatures to return a ModelAndView instead. Otherwise, the structure is similar - I used Tom van Zummeren's post as a guide, you will find more details there.
Status Report Controller
The status report controller produces a nested list (project/item/task), along with task completion statistics based on the actual versus estimated hours for the task during the time period chosen. The time taken to prepare this manually is quite short - it takes me about 10 minutes to reconstruct this from cryptic entries in my planner (and yes, I use one of those :-)). But if you are diligent about entering your hours into KTM, then this is just a matter of a few mouseclicks. Here is what the input and output look like for this report.
Cost Center Report Controller
This report produces a matrix of project hours for each client, broken up by whether the hours are billable, investment, maintenance, etc. We have to prepare this monthly, and usually takes between 15-30 minutes, since we are essentially rolling up from tasks. One of the problems I have with this report is that it is hard for a developer to know whether the task is maintenance or investment, or whether a group of tasks that spans multiple clients should be classified as such, or if it should be treated as an internal project. If the projects are entered in KTM, a manager (who is presumably more in touch with the business aspect of the project) makes this determination. Plus the developer gets to skip having to do the task hours aggregation manually. Here are some screenshots on what the report looks like:
For both the status and cost center reports, I plan on adding the capability for it to be emailed to the person as a text and Excel spreadsheet attachment respectively, but I haven't gotten to that yet.
Burndown Chart Controller
A burndown chart plots the remaining hours against the project timeline. The ideal line (green) is derived from the aggregated task estimate hours. The actual line (solid blue) is generated by subtracting the hours burnt each day by the project team. The dashed blue line is a linear regression of the points on the actual line.
The burndown chart is useful in a number of ways. For one, the gradient of the ideal line (number of project hours required per day) tells management whether the project timeline is realistic. It also provides a live view of the progress of the project and can signal whether the project will be delayed. Here is a screenshot of an actual short-term project for which I entered some data.
As you can see from the chart above, this project is delayed, and if you look back, there is enough early warning for project managers to take action. However, the real reason for the chart saying what it does is that I did not enter actual hours for all the team members, only my own. You can also drill down further and generate the chart for person in an attempt to find the person(s) who are jeopardizing the project. Here is the chart for this project that considers only my project hours.
As you can see, its not me :-) - my part of the project came in slightly under estimate, but not too much under. This is actually the kind of chart most project managers want to see. Developers tend to pad their estimates (to deal with the inevitable unforseen tasks in poorly managed projects), leading to an inaccurate estimate for the entire project - as you use burndown charts more and more, the feedback you get from these charts provides you with information that helps you plan better and make surprisingly accurate estimates.
But enough about the wonders of burndown charts - if you've ever used them, you probably know all this anyway. Let's get down into the code and see how this chart is generated, and how the controller is integrated into the Roo generated KTM app.
Controller
Clicking the "Burndown Chart" link from the left nav of the KTM app hands control to the BurndownChartController.index() method, which populates the form object and sends off to index.jspx, which displays a form containing a dropdown of Projects, a dropdown containing YorN enum values, and a dropdown of Persons. Clicking the "Generate" button will hand control back to the BurndownChartController.post() method, which then delegates to the BurndownChart service to extract the relevant data from the database and populate the ModelAndView, and send off to show.jspx, which renders the page from the data in the ModelAndView.
For the other two report controllers, the flow ends here. For the Burndown Chart, we also need to generate the image. So the show.jspx displays the Team Statistics chart at the top and renders an <img> tag which points back to the BurndownChartController.graph() method. The graph() method extracts the parameters from the URL and delegates to the BurndownChart service to build a JFreeChart object, which is then converted to a PNG bytestream by the controller's graph method and written directly to its response stream.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | // Source: src/main/java/com/healthline/ktm/web/BurndownChartController.java
package com.healthline.ktm.web;
import java.io.OutputStream;
import java.util.List;
import java.util.Map;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.log4j.Logger;
import org.jfree.chart.ChartUtilities;
import org.jfree.chart.JFreeChart;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
import com.healthline.ktm.domain.Person;
import com.healthline.ktm.domain.Project;
import com.healthline.ktm.domain.YorN;
import com.healthline.ktm.fbo.BurndownChartForm;
import com.healthline.ktm.reports.BurndownChart;
@RequestMapping("/burndownchart/**")
@Controller
public class BurndownChartController {
private final Logger logger = Logger.getLogger(getClass());
@Autowired BurndownChart chart;
@ModelAttribute("burndownChartForm")
public BurndownChartForm formBackingObject() {
return new BurndownChartForm();
}
@RequestMapping(value="/burndownchart/index")
public ModelAndView index() {
ModelAndView mav = new ModelAndView();
prepareForm(mav);
mav.setViewName("burndownchart/index");
return mav;
}
@RequestMapping(value="/burndownchart/show", method = RequestMethod.POST)
public ModelAndView post(
@ModelAttribute("burndownChartForm") BurndownChartForm form,
BindingResult result) {
ModelAndView mav = new ModelAndView();
if (result.hasErrors()) {
prepareForm(mav);
mav.setViewName("burndownchart/index");
} else {
mav.addObject("form", form);
mav.addObject("project", chart.getProject(form.getProjectId()));
if (form.getFilterByPerson() == YorN.Yes) {
mav.addObject("person", chart.getPerson(form.getPersonId()));
}
List<List<Object>> teamStats = chart.getTeamStats(form);
mav.addObject("teamStats", teamStats);
Map<String,String> params = chart.getChartParams(form);
mav.addObject("from", params.get("from"));
mav.addObject("to", params.get("to"));
mav.addObject("est", params.get("est"));
mav.addObject("act", params.get("act"));
mav.setViewName("burndownchart/show");
}
return mav;
}
@RequestMapping(value="/burndownchart/graph", method = RequestMethod.GET)
public ModelAndView graph(HttpServletRequest req, HttpServletResponse res)
throws Exception {
String from = ServletRequestUtils.getRequiredStringParameter(req, "from");
String to = ServletRequestUtils.getRequiredStringParameter(req, "to");
int totalEstHrs = ServletRequestUtils.getRequiredIntParameter(req, "est");
String actualHrs =
ServletRequestUtils.getRequiredStringParameter(req, "act");
JFreeChart graph = chart.getChart(from, to, totalEstHrs, actualHrs);
OutputStream ostream = res.getOutputStream();
ChartUtilities.writeChartAsPNG(ostream, graph, 600, 400);
ostream.flush();
ostream.close();
return null;
}
private void prepareForm(ModelAndView mav) {
mav.addObject("persons", Person.findAllPeople());
mav.addObject("projects", Project.findAllProjects());
mav.addObject("yorn_enum", YorN.values());
}
}
|
In this case, there is no validation required, since all the input fields are dropdowns, but in case we wanted one, we can create a Validator implementation and inject it into the controller using @Autowired, and call it using validator.validate(form) in the post() method.
Form
The controller references a form backing object which is just a POJO to hold the form elements that are selected before the user hits the "Generate" button. Here it is. The @ModelAttribute annotation tells the code what it will be called in the JSP.
1 2 3 4 5 6 7 8 9 10 11 12 13 | // Source: src/main/java/com/healthline/ktm/fbo/BurndownChartForm.java
package com.healthline.ktm.fbo;
import com.healthline.ktm.domain.YorN;
public class BurndownChartForm {
private Long projectId;
private Long personId;
private YorN filterByPerson;
// getters and setters omitted - use your IDE to generate them
}
|
Service
The service is where most of the data extraction and computations happen. I like putting things into a separate layer, rather than put them in my controller, but this is mostly a personal preference, you can just as well put this code into the controller, since that is the only class using these methods.
I'll skip the explanations, except to note that the getTeamStats() and getChartParams() are called from the controller's post() method, and the getChart() is called from the controller's graph() method. Apart from this, the code is mostly self explanatory and has inline comments which may be helpful.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 | // Source: src/main/java/com/healthline/ktm/reports/BurndownChart.java
package com.healthline.ktm.reports;
import java.awt.BasicStroke;
import java.awt.Color;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.persistence.Query;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.math.stat.regression.SimpleRegression;
import org.apache.log4j.Logger;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.axis.CategoryAxis;
import org.jfree.chart.axis.CategoryLabelPositions;
import org.jfree.chart.axis.NumberAxis;
import org.jfree.chart.labels.StandardCategoryItemLabelGenerator;
import org.jfree.chart.plot.CategoryPlot;
import org.jfree.chart.plot.PlotOrientation;
import org.jfree.chart.renderer.category.LineAndShapeRenderer;
import org.jfree.data.category.DefaultCategoryDataset;
import org.springframework.stereotype.Service;
import com.healthline.ktm.domain.Allocations;
import com.healthline.ktm.domain.Hours;
import com.healthline.ktm.domain.Person;
import com.healthline.ktm.domain.Project;
import com.healthline.ktm.domain.Task;
import com.healthline.ktm.domain.YorN;
import com.healthline.ktm.fbo.BurndownChartForm;
@Service("burndownChartService")
public class BurndownChart {
private final Logger logger = Logger.getLogger(getClass());
private static final SimpleDateFormat DATE_FORMATTER =
new SimpleDateFormat("MM/dd/yyyy");
private static final DecimalFormat NUMBER_FORMATTER =
new DecimalFormat("###");
private static final Color DARK_GREEN = new Color(1, 186, 1);
private static final int NUM_WORKING_HRS_PER_DAY = 8;
private static final int[] WEEKEND_DAYS = {
Calendar.SATURDAY, Calendar.SUNDAY
};
private static class Tuple<A,B> {
public A first;
public B second;
public Tuple(A first, B second) {
this.first = first;
this.second = second;
}
};
public Project getProject(Long projectId) {
return Project.findProject(projectId);
}
public Person getPerson(Long personId) {
return Person.findPerson(personId);
}
public List<List<Object>> getTeamStats(BurndownChartForm form) {
Project project = getProject(form.getProjectId());
// find number of WORKING DAYS (minus Saturday and Sunday)
Date startDate = project.getStartDate();
Date endDate = project.getEndDate();
Calendar startCal = Calendar.getInstance();
startCal.setTime(startDate);
Calendar endCal = Calendar.getInstance();
endCal.setTime(endDate);
int numWorkingDays = 0;
for (;;) {
if (isWorkingDay(startCal)) {
numWorkingDays++;
}
startCal.add(Calendar.DATE, 1);
if (startCal.after(endCal)) {
break;
}
}
// calculate person stats
List<Person> persons = new ArrayList<Person>();
if (form.getFilterByPerson() == YorN.Yes) {
persons.add(getPerson(form.getPersonId()));
} else {
persons.addAll(Person.findAllPeople());
}
Map<Person,Map<String,Object>> stats =
new HashMap<Person,Map<String,Object>>();
for (Person person : persons) {
Map<String,Object> personStats = new HashMap<String,Object>();
Query allocationQuery = Allocations.findAllocationsesByAssignee(person);
List<Allocations> allocations =
(List<Allocations>) allocationQuery.getResultList();
for (Allocations allocation : allocations) {
if (project.equals(allocation.getProject())) {
personStats.put("allocationPercent",
allocation.getPercentAllocated());
break;
}
}
stats.put(person, personStats);
}
// find tasks by project and aggregate estimated hours across person
Query tasksByProjectQuery = Task.findTasksByProject(project);
List<Task> tasks = (List<Task>) tasksByProjectQuery.getResultList();
for (Task task : tasks) {
Person person = task.getAssignee();
Map<String,Object> personStats = stats.get(person);
if (personStats == null && form.getFilterByPerson() == YorN.Yes) {
// we don't want to show results for this person
continue;
}
if (personStats.containsKey("estimatedHours")) {
Integer estimatedHours = (Integer) personStats.get("estimatedHours");
estimatedHours += task.getEstimatedHours();
personStats.put("estimatedHours", estimatedHours);
} else {
personStats.put("estimatedHours", task.getEstimatedHours());
}
}
// calculate the level of effort for each person in the team and
// build up team stats for display
List<List<Object>> teamStats = new ArrayList<List<Object>>();
for (Person person : stats.keySet()) {
Map<String,Object> personStats = stats.get(person);
if (! personStats.containsKey("estimatedHours")) {
continue;
}
int allocationPercent = 100; // default
if (personStats.containsKey("allocationPercent")) {
allocationPercent = (Integer) personStats.get("allocationPercent");
}
float availableHrsPerDay =
(float) (allocationPercent * NUM_WORKING_HRS_PER_DAY / 100);
float availableHrs = availableHrsPerDay * numWorkingDays;
float estimatedHrs = (Integer) personStats.get("estimatedHours");
float levelOfEffort = estimatedHrs / numWorkingDays;
List<Object> teamStat = new ArrayList<Object>();
teamStat.add(person.getName());
teamStat.add(numWorkingDays);
teamStat.add(estimatedHrs);
teamStat.add(allocationPercent);
teamStat.add(availableHrs);
teamStat.add(levelOfEffort);
teamStats.add(teamStat);
}
return teamStats;
}
public Map<String,String> getChartParams(BurndownChartForm form) {
Map<String,String> chartParams = new HashMap<String,String>();
Project project = getProject(form.getProjectId());
// find the start and stop dates
Date startDate = project.getStartDate();
chartParams.put("from", DATE_FORMATTER.format(startDate));
Date endDate = project.getEndDate();
chartParams.put("to", DATE_FORMATTER.format(endDate));
Person person = (form.getFilterByPerson() == YorN.Yes) ?
getPerson(form.getPersonId()) : null;
List<Tuple<Date,Integer>> actualHrs = new ArrayList<Tuple<Date,Integer>>();
Query taskQuery = Task.findTasksByProject(project);
List<Task> tasks = (List<Task>) taskQuery.getResultList();
int totalEstimatedHours = 0;
for (Task task : tasks) {
if (person != null) {
if (task.getAssignee().equals(person)) {
totalEstimatedHours += task.getEstimatedHours();
}
} else {
totalEstimatedHours += task.getEstimatedHours();
}
Query hoursQuery = Hours.findHoursesByTask(task);
List<Hours> hours = (List<Hours>) hoursQuery.getResultList();
if (hours != null && hours.size() > 0) {
for (Hours hour : hours) {
actualHrs.add(new Tuple<Date,Integer>(
hour.getRecordedDate(), hour.getActualHours()));
}
}
}
chartParams.put("est", String.valueOf(totalEstimatedHours));
chartParams.put("act", pack(actualHrs));
return chartParams;
}
public JFreeChart getChart(String from, String to, int totalEstHrs,
String actualHours) throws Exception {
// calculate the list of dates between the project start and end dates
Date fromDate = DATE_FORMATTER.parse(from);
Date toDate = DATE_FORMATTER.parse(to);
List<String> xaxis = new ArrayList<String>();
Calendar fromCal = Calendar.getInstance();
fromCal.setTime(fromDate);
Calendar toCal = Calendar.getInstance();
List<String> dates = new ArrayList<String>();
toCal.setTime(toDate);
for (;;) {
dates.add(DATE_FORMATTER.format(fromCal.getTime()));
fromCal.add(Calendar.DATE, 1);
if (fromCal.after(toCal)) {
break;
}
}
// build up the series for estimated line
DefaultCategoryDataset estimatedLineData = new DefaultCategoryDataset();
int remainingHours = totalEstHrs;
for (String date : dates) {
estimatedLineData.addValue(remainingHours, "Estimated", date);
remainingHours -= (totalEstHrs / (dates.size() - 1));
}
// build up the series for actual line
DefaultCategoryDataset actualLineData = new DefaultCategoryDataset();
remainingHours = totalEstHrs;
Map<String,Integer> actualHoursByDate = unpack(actualHours);
List<Integer> actuals = new ArrayList<Integer>();
for (String date : dates) {
if (actualHoursByDate.containsKey(date)) {
remainingHours -= actualHoursByDate.get(date);
actualLineData.addValue(remainingHours, "Actuals", date);
} else {
actualLineData.addValue(remainingHours, "Actuals", date);
}
actuals.add(remainingHours);
}
// build up the series for least squares regression on actual line
DefaultCategoryDataset fittedActualLineData = new DefaultCategoryDataset();
SimpleRegression bestfit = new SimpleRegression();
int nActuals = actuals.size();
for (int i = 0; i < nActuals; i++) {
bestfit.addData((double) i, (double) actuals.get(i));
}
double intercept = bestfit.getIntercept();
double slope = bestfit.getSlope();
for (String date : dates) {
fittedActualLineData.addValue(intercept, "Fitted Actuals", date);
intercept += slope; // slope is going to be negative
}
// set up the chart
JFreeChart chart = ChartFactory.createLineChart(
"", // title
"Timeline", // x-axis legend
"Remaining Hours", // y-axis legend
estimatedLineData, // populate them en-masse later
PlotOrientation.VERTICAL, // orientation
true, // show legends
true, // show tooltips
false); // show urls
// general body customization
chart.setBackgroundPaint(Color.WHITE);
CategoryPlot plot = (CategoryPlot) chart.getPlot();
plot.setBackgroundPaint(Color.LIGHT_GRAY);
plot.setDomainGridlinePaint(Color.WHITE);
plot.setRangeGridlinePaint(Color.WHITE);
plot.setDomainGridlineStroke(CategoryPlot.DEFAULT_GRIDLINE_STROKE);
plot.setDomainGridlinesVisible(true);
// customize X-Axis
CategoryAxis domainAxis = plot.getDomainAxis();
domainAxis.setCategoryLabelPositions(CategoryLabelPositions.UP_45);
domainAxis.setTickLabelsVisible(true);
domainAxis.setCategoryLabelPositionOffset(0);
// customize Y-Axis
NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis();
rangeAxis.setLowerBound(0D);
rangeAxis.setUpperBound(totalEstHrs);
// display data values
DefaultCategoryDataset[] dataSets = new DefaultCategoryDataset[] {
estimatedLineData, actualLineData, fittedActualLineData
};
Color[] colors = new Color[] {DARK_GREEN, Color.BLUE, Color.BLUE};
BasicStroke[] styles = new BasicStroke[] {
new BasicStroke(1.0F, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_MITER),
new BasicStroke(1.0F, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_MITER),
new BasicStroke(1.0F, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_MITER,
10.0F, new float[] {5.0F}, 0.0F)
};
for (int i = 0; i < dataSets.length; i++) {
LineAndShapeRenderer renderer = new LineAndShapeRenderer();
plot.setDataset(i, dataSets[i]);
renderer.setSeriesItemLabelGenerator(i,
new StandardCategoryItemLabelGenerator(
StandardCategoryItemLabelGenerator.DEFAULT_LABEL_FORMAT_STRING,
NUMBER_FORMATTER));
renderer.setSeriesStroke(0, styles[i]);
renderer.setSeriesPaint(0, colors[i]);
renderer.setSeriesItemLabelsVisible(0, false);
renderer.setBaseItemLabelsVisible(false);
plot.setRenderer(i, renderer);
}
return chart;
}
private boolean isWorkingDay(Calendar cal) {
boolean isWorkingDay = true;
int today = cal.get(Calendar.DAY_OF_WEEK);
for (int weekend = 0; weekend < WEEKEND_DAYS.length; weekend++) {
if (today == weekend) {
isWorkingDay = false;
break;
}
}
return isWorkingDay;
}
private String pack(List<Tuple<Date,Integer>> tuples) {
StringBuilder buf = new StringBuilder();
int i = 0;
for (Tuple<Date,Integer> tuple : tuples) {
if (i > 0) {
buf.append(";");
}
buf.append(DATE_FORMATTER.format(tuple.first)).
append(",").
append(String.valueOf(tuple.second));
i++;
}
return buf.toString();
}
private Map<String,Integer> unpack(String packed) throws Exception {
Map<String,Integer> actualsMap = new HashMap<String,Integer>();
String[] tupleStrs = StringUtils.split(packed, ";");
for (String tupleStr : tupleStrs) {
String[] elements = StringUtils.split(tupleStr, ",");
if (actualsMap.containsKey(elements[0])) {
int actualHours = actualsMap.get(elements[0]);
actualHours += Integer.valueOf(elements[1]);
} else {
actualsMap.put(elements[0], Integer.valueOf(elements[1]));
}
}
return actualsMap;
}
}
|
index.jspx
The index.jspx is adapted from one of the Roo generated create.jspx files. I have reformatted it for my own understanding. This jspx represents the form that is displayed when the "Burndown Chart" link is clicked.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | <%-- Source: src/main/webapp/WEB-INF/views/burndownchart/index.jspx --%>
<div xmlns:spring="http://www.springframework.org/tags"
xmlns:form="http://www.springframework.org/tags/form"
xmlns:jsp="http://java.sun.com/JSP/Page"
version="2.0">
<jsp:output omit-xml-declaration="yes" />
<script type="text/javascript">
dojo.require("dijit.TitlePane");
dojo.require("dijit.form.SimpleTextarea");
dojo.require("dijit.form.FilteringSelect");
</script>
<div id="_title_div">
<spring:message var="app_name" code="burndownchart.title" />
<spring:message var="title" code="burndownchart.title"/>
<script type="text/javascript">
Spring.addDecoration(new Spring.ElementDecoration(
{elementId : '_title_div', widgetType : 'dijit.TitlePane',
widgetAttrs : {title: '${title}'}}));
</script>
<form:form action="/ktm/burndownchart/show" method="POST"
commandName="burndownChartForm">
<form:errors cssClass="errors" delimiter="<p/>"/>
<br/>
<div id="burndownchart_projectname">
<label for="_projectname_id">Project:</label>
<form:select cssStyle="width:250px" id="_projectname_id"
path="projectId">
<form:options itemValue="id" items="${projects}"/>
</form:select>
<script type="text/javascript">
Spring.addDecoration(new Spring.ElementDecoration(
{elementId : '_projectname_id',
widgetType: 'dijit.form.FilteringSelect',
widgetAttrs : {hasDownArrow : true}}));
</script>
</div>
<br/>
<div id="burndownchart_filterbyperson">
<label for="_filterbyperson_id">Filter By Person:</label>
<form:select cssStyle="width:250px" id="_filterbyperson_id"
items="${yorn_enum}" path="filterByPerson"/>
<script type="text/javascript">
Spring.addDecoration(new Spring.ElementDecoration(
{elementId : '_filterbyperson_id',
widgetType: 'dijit.form.FilteringSelect',
widgetAttrs : {hasDownArrow : true}}));
</script>
</div>
<br/>
<div id="burndownchart_personname">
<label for="_personname_id">Person (optional):</label>
<form:select cssStyle="width:250px" id="_personname_id"
path="personId">
<form:options itemValue="id" items="${persons}"/>
</form:select>
<script type="text/javascript">
Spring.addDecoration(new Spring.ElementDecoration(
{elementId : '_personname_id',
widgetType: 'dijit.form.FilteringSelect',
widgetAttrs : {hasDownArrow : true}}));
</script>
</div>
<br/>
<div class="submit" id="burndownchart_submit">
<spring:message code="burndownchart.generate" var="save_button"/>
<script type="text/javascript">
Spring.addDecoration(new Spring.ValidateAllDecoration(
{elementId:'proceed', event:'onclick'}));
</script>
<input id="proceed" type="submit" value="${save_button}"/>
</div>
</form:form>
</div>
</div>
|
show.jspx
Here is the show.jspx file that contains the formatting code for the Team Statistics table and the image tag for the chart.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | <%-- Source: src/main/webapp/WEB-INF/views/burndownchart/show.jspx --%>
<div xmlns:spring="http://www.springframework.org/tags"
xmlns:form="http://www.springframework.org/tags/form"
xmlns:jsp="http://java.sun.com/JSP/Page"
xmlns:fmt="http://java.sun.com/jsp/jstl/fmt"
xmlns:c="http://java.sun.com/jsp/jstl/core"
version="2.0">
<jsp:output omit-xml-declaration="yes" />
<script type="text/javascript">
dojo.require("dijit.TitlePane");
dojo.require("dijit.form.SimpleTextarea");
dojo.require("dijit.form.FilteringSelect");
</script>
<div id="_title_div">
<spring:message var="app_name" code="burndownchart.title" />
<spring:message var="title" code="burndownchart.title"/>
<script type="text/javascript">
Spring.addDecoration(new Spring.ElementDecoration(
{elementId : '_title_div', widgetType : 'dijit.TitlePane',
widgetAttrs : {title: '${title}'}}));
</script>
<h3>
${project.name} Burndown
<c:if test="${person != null}">(for ${person.name})</c:if>
</h3>
<b>Team Statistics</b>
<table cellspacing="1" cellpadding="1" border="1">
<tr>
<th>Person</th>
<th>#-Work Days</th>
<th>Total Est.Hrs</th>
<th>Allocation-%</th>
<th>Avail Hrs</th>
<th>Est.LOE (Hrs/Day)</th>
</tr>
<c:forEach items="${teamStats}" var="teamStat">
<tr>
<c:forEach items="${teamStat}" var="personStat">
<td>${personStat}</td>
</c:forEach>
</tr>
</c:forEach>
</table>
<br/><br/>
<img src="/ktm/burndownchart/graph?from=${from}&to=${to}&est=${est}&act=${act}"/>
</div>
</div>
|
I also had to put in an entry for show.jspx in the views.xml in the burndownchart directory.
1 2 3 4 5 6 | <tiles-definitions>
...
<definition extends="default" name="burndownchart/show">
<put-attribute name="body" value="/WEB-INF/views/burndownchart/show.jspx"/>
</definition>
</tiles-definitions>
|
messages.properties
Finally, we pull out the title and the submit button legend out into an i18n aware properties file (messages.properties). Not strictly required if you don't care about i18n, but I figured I may as well follow the pattern established by Roo - I probably should pull out other fields as well though.
1 2 3 4 5 | # Source: src/main/webapp/WEB-INF/i18n/messages.properties
...
#burndownchart
burndownchart.title=Burndown Chart
burndownchart.generate=Generate
|
Conclusion
Having completed (well, mostly, except for some minor enhancements) an app using Roo, here are some thoughts. Building the reports was quite a bit of work, but interesting work. Roo took care of most of the grunt work in this project - the generic application it generated is usable, although it could probably use some (okay, a lot of) improvement, especially in the validation department.
Most of the time spent in customizing the basic application involved understanding how the generated app is structured, so I can go in and customize the right places and structuring the changes so there is the least possibility of Roo overwriting them again. Since Roo is currently under active development, its something of a moving target, but I believe I know enough now so developing the next Roo app would be much quicker.
I believe Roo can give Java web developers the kind of productivity boost when they moved from Ant to Maven as their build tool - suddenly you could just generate similar, yet different project skeletons with a single command. Roo goes one step further and generates a complete working application for you.
However, like Maven, Roo needs more plugins to generate different app "archetypes". Smart users can then define a company wide application archetype that can be reused to produce applications with a consistent look and feel.
It would also be good to have more persistence options - while I agree that databases are still the most popular, apps that do not use them cannot take advantage of Roo.
I think both issues can be handled by the Roo team providing good documentation about customizing Roo at the system level, perhaps HOWTOs on how to write your own persistence layer, or how to write your own application template. This would also accelarate Roo development and adoption, as some of these plugins are likely to be contributed back to the Roo project.
Thanks Joven.
ReplyDeleteThis project is looking up.
ReplyDeleteIs the full code available somewhere? It may be useful to others if it were on sourceforge or google code.
No, I don't have it on a public repository. I think its probably too specific for my use case (custom reports, etc), so its not really worth putting up. The only thing that people may want is the code for the chart, and thats all available on the post anyway.
ReplyDelete