Friday, August 06, 2010

KTM - Roo Manual Controllers

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="&lt;p/&gt;"/>
      <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}&amp;to=${to}&amp;est=${est}&amp;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.

3 comments:

  1. This project is looking up.

    Is the full code available somewhere? It may be useful to others if it were on sourceforge or google code.

    ReplyDelete
  2. 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

Comments are moderated to prevent spam.