Index: trac/ticket/tests/roadmap.py
===================================================================
--- trac/ticket/tests/roadmap.py	(revision 5786)
+++ trac/ticket/tests/roadmap.py	(working copy)
@@ -74,7 +74,7 @@
 
         tkt1 = Ticket(self.env)
         tkt1.populate({'summary': 'Foo', 'milestone': 'Test', 'owner': 'foman',
-                        'status': 'open'})
+                        'status': 'new'})
         tkt1.insert()
         tkt2 = Ticket(self.env)
         tkt2.populate({'summary': 'Bar', 'milestone': 'Test',
@@ -90,6 +90,7 @@
         
         prov = DefaultTicketGroupStatsProvider(ComponentManager())
         prov.env = self.env
+        prov.config = self.env.config
         self.stats = prov.get_ticket_group_stats([tkt1.id, tkt2.id, tkt3.id])
 
     def test_stats(self):
@@ -101,9 +102,9 @@
         closed = self.stats.intervals[0]
         self.assertEquals('closed', closed['title'], 'closed title incorrect')
         self.assertEquals('closed', closed['css_class'], 'closed class incorrect')
-        self.assertEquals(True, closed['countsToProg'],
-                          'closed not count to prog')
-        self.assertEquals({'status': 'closed', 'group': 'resolution'},
+        self.assertEquals(True, closed['overall_completion'],
+                          'closed should contribute to overall completion')
+        self.assertEquals({'status': ['closed'], 'group': 'resolution'},
                           closed['qry_args'], 'qry_args incorrect')
         self.assertEquals(1, closed['count'], 'closed count incorrect')
         self.assertEquals(33, closed['percent'], 'closed percent incorrect')
@@ -112,9 +113,10 @@
         open = self.stats.intervals[1]
         self.assertEquals('active', open['title'], 'open title incorrect')
         self.assertEquals('open', open['css_class'], 'open class incorrect')
-        self.assertEquals(False, open['countsToProg'],
-                          'open not count to prog')
-        self.assertEquals({'status': ['new', 'assigned', 'reopened']},
+        self.assertEquals(False, open['overall_completion'],
+                          "open shouldn't contribute to overall completion")
+        self.assertEquals({'status':
+                           [u'assigned', u'new', u'accepted', u'reopened']},
                           open['qry_args'], 'qry_args incorrect')
         self.assertEquals(2, open['count'], 'open count incorrect')
         self.assertEquals(67, open['percent'], 'open percent incorrect')
Index: trac/ticket/roadmap.py
===================================================================
--- trac/ticket/roadmap.py	(revision 5786)
+++ trac/ticket/roadmap.py	(working copy)
@@ -26,7 +26,7 @@
 from trac.context import IContextProvider, Context
 from trac.core import *
 from trac.perm import IPermissionRequestor
-from trac.util import sorted
+from trac.util.compat import set, sorted
 from trac.util.datefmt import parse_date, utc, to_timestamp, \
                               get_date_format_hint, get_datetime_format_hint
 from trac.util.text import shorten_line, CRLF, to_unicode
@@ -64,7 +64,8 @@
         self.done_percent = 0
         self.done_count = 0
 
-    def add_interval(self, title, count, qry_args, css_class, countsToProg=0):
+    def add_interval(self, title, count, qry_args, css_class,
+                     overall_completion=None, countsToProg=0):
         """Adds a division to this stats' group's progress bar.
 
         `title` is the display name (eg 'closed', 'spent effort') of this
@@ -73,16 +74,22 @@
         `qry_args` is a dict of extra params that will yield the subset of
           tickets in this interval on a query.
         `css_class` is the css class that will be used to display the division.
-        `countsToProg` can be set to true to make this interval count towards
-          overall completion of this group of tickets.
+        `overall_completion` can be set to true to make this interval count
+          towards overall completion of this group of tickets.
+          
+        (Warning: `countsToProg` argument will be removed in 0.12, use
+        `overall_completion` instead)
         """
+        if overall_completion is None:
+            overall_completion = countsToProg
         self.intervals.append({
             'title': title,
             'count': count,
             'qry_args': qry_args,
             'css_class': css_class,
             'percent': None,
-            'countsToProg': countsToProg
+            'countsToProg': overall_completion,
+            'overall_completion': overall_completion,
         })
         self.count = self.count + count
 
@@ -96,42 +103,134 @@
             interval['percent'] = round(float(interval['count'] / 
                                         float(self.count) * 100))
             total_percent = total_percent + interval['percent']
-            if interval['countsToProg']:
+            if interval['overall_completion']:
                 self.done_percent += interval['percent']
                 self.done_count += interval['count']
 
+        # We want the percentages to add up to 100%.  To do that, we fudge the
+        # first interval that counts as "completed".  That interval is adjusted
+        # by enough to make the intervals sum to 100%.
         if self.done_count and total_percent != 100:
-            fudge_int = [i for i in self.intervals if i['countsToProg']][0]
+            fudge_int = [i for i in self.intervals
+                         if i['overall_completion']][0]
             fudge_amt = 100 - total_percent
             fudge_int['percent'] += fudge_amt
             self.done_percent += fudge_amt
 
+
 class DefaultTicketGroupStatsProvider(Component):
+    """Configurable ticket group statistics provider.
+
+    Example configuration (which is also the default):
+    {{{
+    [milestone-groups]
+
+    # Definition of a 'closed' group:
+    
+    closed = closed
+
+    # The definition consists in a comma-separated list of accepted status.
+    # The list could be prefixed by '!', meaning it's a list  of rejected
+    # status instead.
+    # Also, '*' means any status and could be used for the last group as a
+    # catch-all expression.
+
+    # Qualifiers for the above group (the group must have been defined first):
+    
+    closed.order = 0                     # sequence number in the progress bar
+    closed.args = group=resolution       # optional extra param for the query
+    closed.overall_completion = true     # count for overall completion
+
+    # Definition of an 'active' group:
+
+    active = *
+    active.order = 1
+    active.css = open                    # CSS class for this interval
+
+    # The CSS class can be one of: new (yellow), open (no color) or
+    # closed (green). New styles can easily be added using the following
+    # selector:  `table.progress td.<class>`
+    }}}
+    """
+    
     implements(ITicketGroupStatsProvider)
 
+    def _get_ticket_groups(self):
+        """Returns a dict describing the ticket groups used in milestone
+        progress bars.
+        """
+        if 'milestone-groups' in self.config:
+            groups = {}
+            order = 0
+            for option, value in self.config.options('milestone-groups'):
+                if '.' in option:
+                    name, qualifier = option.split('.', 1)
+                    group = groups.get(name)
+                    if group:
+                        group[qualifier] = value
+                    else:
+                        raise TracError("%s milestone group qualified before "
+                                        " being defined" % name)
+                else:
+                    groups[option] = {'name': option, 'status': value,
+                                      'order': order}
+                    order += 1
+            return [group for group in sorted(groups.values(),
+                                              key=lambda g: int(g['order']))]
+        else:
+            return [{'name': 'closed', 'status': 'closed',
+                     'args': 'group=resolution', 'overall_completion': 'true'},
+                    {'name': 'active', 'status': '*', 'css': 'open'}]
+
     def get_ticket_group_stats(self, ticket_ids):
         total_cnt = len(ticket_ids)
+        all_status = set(TicketSystem(self.env).get_all_status())
+        status_cnt = {}
+        for s in all_status:
+            status_cnt[s] = 0
         if total_cnt:
             cursor = self.env.get_db_cnx().cursor()
             str_ids = [str(x) for x in sorted(ticket_ids)]
-            active_cnt = cursor.execute("SELECT count(1) FROM ticket "
-                                        "WHERE status <> 'closed' AND id IN "
-                                        "(%s)" % ",".join(str_ids))
-            active_cnt = 0
-            for cnt, in cursor:
-                active_cnt = cnt
-        else:
-            active_cnt = 0
+            cursor.execute("SELECT status, count(status) FROM ticket "
+                           "WHERE id IN (%s) GROUP BY status" %
+                           ",".join(str_ids))
+            for s, cnt in cursor:
+                status_cnt[s] = cnt
 
-        closed_cnt = total_cnt - active_cnt
-
         stat = TicketGroupStats('ticket status', 'ticket')
-        stat.add_interval('closed', closed_cnt,
-                          {'status': 'closed', 'group': 'resolution'},
-                          'closed', True)
-        stat.add_interval('active', active_cnt,
-                          {'status': ['new', 'assigned', 'reopened']},
-                          'open', False)
+        for group in self._get_ticket_groups():
+            group_cnt = 0
+            status_list = group['status'].strip()
+            if status_list == '*':
+                accepted = all_status
+                all_status = set()
+            else:
+                invert = False
+                if status_list.startswith('!'):
+                    status_list = status_list[1:]
+                    invert = True
+                accepted = set([s.strip() for s in status_list.split(',')])
+                if invert:
+                    all_status = accepted
+                elif accepted - all_status:
+                    raise TracError("%s milestone group reused status %s "
+                                    "already taken by other groups. "
+                                    "Please check your configuration." %
+                                    ', '.join(accepted - all_status))
+                else:
+                    all_status -= accepted
+            query_args = {}
+            for s, cnt in status_cnt.iteritems():
+                if (s in accepted) ^ invert:
+                    group_cnt += cnt
+                    query_args.setdefault('status', []).append(s)
+            for arg in [kv for kv in group.get('args', '').split(',')
+                        if '=' in kv]:
+                k, v = [a.strip() for a in arg.split('=', 1)]
+                query_args[k] = v
+            stat.add_interval(group['name'], group_cnt, query_args,
+                              group.get('css', group['name']),
+                              bool(group.get('overall_completion')))
         stat.refresh_calcs()
         return stat
 
@@ -636,8 +735,10 @@
 
             for idx, gstat in enumerate(group_stats):
                 gs_dict = milestone_groups[idx]
-                gs_dict['percent_of_max_total'] = (float(gstat.count) /
-                                                   float(max_count) * 100)
+                percent = 1.0
+                if max_count:
+                    percent = float(gstat.count) / float(max_count) * 100
+                gs_dict['percent_of_max_total'] = percent
 
         return 'milestone_view.html', data, None
 
Index: trac/ticket/workflows/basic-workflow.ini
===================================================================
--- trac/ticket/workflows/basic-workflow.ini	(revision 5786)
+++ trac/ticket/workflows/basic-workflow.ini	(working copy)
@@ -21,3 +21,16 @@
 reopen = closed -> reopened
 reopen.permissions = TICKET_CREATE
 reopen.operations = del_resolution
+
+[milestone-groups]
+closed = closed
+closed.order = 0
+closed.args = group=resolution
+closed.overall_completion = true
+
+active = assigned,accepted
+active.order = 1
+active.css = open
+
+new = new,reopened
+new.order = 2
Index: trac/htdocs/css/roadmap.css
===================================================================
--- trac/htdocs/css/roadmap.css	(revision 5786)
+++ trac/htdocs/css/roadmap.css	(working copy)
@@ -19,6 +19,7 @@
  text-decoration: none
 }
 table.progress td { background: #fff; padding: 0 }
+table.progress td.new { background: #f5f5b5 }
 table.progress td.closed { background: #bae0ba }
 table.progress td :hover { background: none }
 p.percent { font-size: 10px; line-height: 2.4em; margin: 0.9em 0 0 }
Index: trac/templates/macros.html
===================================================================
--- trac/templates/macros.html	(revision 5786)
+++ trac/templates/macros.html	(working copy)
@@ -237,7 +237,7 @@
         <dd><a href="${interval_hrefs[idx]}">${interval.count}</a></dd>
       </py:for>
       <py:if test="stats_href">
-        <dt>Total ${stats.unit}s:</dt>
+        <dt>/ Total ${stats.unit}s:</dt>
         <dd><a href="${stats_href}">${sum([x.count for x in stats.intervals], 0)}</a></dd>
       </py:if>
     </dl>

