Edgewall Software

Ticket #2314: mstone_stat_ext_r4008.patch

File mstone_stat_ext_r4008.patch, 25.4 KB (added by trac@…, 2 years ago)

patch updated to current trunk and addressing some performance issues

  • trac/ticket/tests/__init__.py

     
    11import unittest 
    22 
    33from trac.ticket.tests import api, model, query, wikisyntax, notification, \ 
    4                               conversion 
     4                              conversion, roadmap 
    55 
    66def suite(): 
    77    suite = unittest.TestSuite() 
    88    suite.addTest(api.suite()) 
    99    suite.addTest(model.suite()) 
    1010    suite.addTest(query.suite()) 
     11    suite.addTest(roadmap.suite()) 
    1112    suite.addTest(wikisyntax.suite()) 
    1213    suite.addTest(notification.suite()) 
    1314    suite.addTest(conversion.suite()) 
  • trac/ticket/tests/roadmap.py

     
     1from trac.config import Configuration 
     2from trac.test import EnvironmentStub 
     3from trac.ticket.roadmap import * 
     4from trac.core import ComponentManager 
     5 
     6import unittest 
     7 
     8class TicketGroupStatsTestCase(unittest.TestCase): 
     9 
     10    def setUp(self): 
     11        self.stats = TicketGroupStats('title', 'unit') 
     12 
     13    def test_init(self): 
     14        self.assertEquals('title', self.stats.title, 'title incorrect') 
     15        self.assertEquals('unit', self.stats.unit, 'unit incorrect') 
     16        self.assertEquals(0, self.stats.count, 'count not zero') 
     17        self.assertEquals(0, len(self.stats.intervals), 'intervals not empty') 
     18 
     19    def test_add_iterval(self): 
     20        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0) 
     21        self.assertEquals(3, self.stats.count, 'count not incremented') 
     22        int = self.stats.intervals[0] 
     23        self.assertEquals('intTitle', int['title'], 'title incorrect') 
     24        self.assertEquals(3, int['count'], 'count incorrect') 
     25        self.assertEquals({'k1': 'v1'}, int['qry_args'], 'query args incorrect') 
     26        self.assertEquals('css', int['css_class'], 'css class incorrect') 
     27        self.assertEquals(100, int['percent'], 'percent incorrect') 
     28        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0) 
     29        self.assertEquals(50, int['percent'], 'percent not being updated') 
     30 
     31    def test_add_interval_no_prog(self): 
     32        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0) 
     33        self.stats.add_interval('intTitle', 5, {'k1': 'v1'}, 'css', 0) 
     34        int = self.stats.intervals[1] 
     35        self.assertEquals(0, self.stats.done_count, 'count added for no prog') 
     36        self.assertEquals(0, self.stats.done_percent, 'percent incremented') 
     37 
     38    def test_add_interval_prog(self): 
     39        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0) 
     40        self.stats.add_interval('intTitle', 1, {'k1': 'v1'}, 'css', 1) 
     41        self.assertEquals(4, self.stats.count, 'count not incremented') 
     42        self.assertEquals(1, self.stats.done_count, 'count not added to prog') 
     43        self.assertEquals(25, self.stats.done_percent, 'done percent not incr') 
     44 
     45    def test_add_interval_fudging(self): 
     46        self.stats.add_interval('intTitle', 3, {'k1': 'v1'}, 'css', 0) 
     47        self.stats.add_interval('intTitle', 5, {'k1': 'v1'}, 'css', 1) 
     48        self.assertEquals(8, self.stats.count, 'count not incremented') 
     49        self.assertEquals(5, self.stats.done_count, 'count not added to prog') 
     50        self.assertEquals(62, self.stats.done_percent, 
     51                          'done percnt not fudged downward') 
     52        self.assertEquals(62, self.stats.intervals[1]['percent'], 
     53                          'interval percent not fudged downward') 
     54        self.assertEquals(38, self.stats.intervals[0]['percent'], 
     55                          'interval percent not fudged upward') 
     56 
     57 
     58class DefaultTicketGroupStatsProviderTestCase(unittest.TestCase): 
     59 
     60    def setUp(self): 
     61        self.env = EnvironmentStub(default_data=True) 
     62 
     63        self.milestone1 = Milestone(self.env) 
     64        self.milestone1.name = 'Test' 
     65        self.milestone1.insert() 
     66        self.milestone2 = Milestone(self.env) 
     67        self.milestone2.name = 'Test2' 
     68        self.milestone2.insert() 
     69 
     70        tkt1 = Ticket(self.env) 
     71        tkt1.populate({'summary': 'Foo', 'milestone': 'Test', 'owner': 'foman', 
     72                        'status': 'open'}) 
     73        tkt1.insert() 
     74        tkt2 = Ticket(self.env) 
     75        tkt2.populate({'summary': 'Bar', 'milestone': 'Test', 
     76                        'status': 'closed', 'owner': 'barman'}) 
     77        tkt2.insert() 
     78        tkt3 = Ticket(self.env) 
     79        tkt3.populate({'summary': 'Sum', 'milestone': 'Test', 'owner': 'suman', 
     80                        'status': 'reopened'}) 
     81        tkt3.insert() 
     82        self.tkt1 = tkt1 
     83        self.tkt2 = tkt2 
     84        self.tkt3 = tkt3 
     85         
     86        prov = DefaultTicketGroupStatsProvider(ComponentManager()) 
     87        prov.env = self.env 
     88        self.stats = prov.get_ticket_group_stats([tkt1.id, tkt2.id, tkt3.id]) 
     89 
     90    def test_stats(self): 
     91        self.assertEquals(self.stats.title, 'ticket status', 'title incorrect') 
     92        self.assertEquals(self.stats.unit, 'ticket', 'unit incorrect') 
     93        self.assertEquals(2, len(self.stats.intervals), 'more than 2 intervals') 
     94 
     95    def test_closed_interval(self): 
     96        closed = self.stats.intervals[0] 
     97        self.assertEquals('closed', closed['title'], 'closed title incorrect') 
     98        self.assertEquals('closed', closed['css_class'], 'closed class incorrect') 
     99        self.assertEquals(True, closed['countsToProg'], 
     100                          'closed not count to prog') 
     101        self.assertEquals({'status': 'closed'}, closed['qry_args'], 
     102                          'qry_args incorrect') 
     103        self.assertEquals(1, closed['count'], 'closed count incorrect') 
     104        self.assertEquals(33, closed['percent'], 'closed percent incorrect') 
     105 
     106    def test_open_interval(self): 
     107        open = self.stats.intervals[1] 
     108        self.assertEquals('active', open['title'], 'open title incorrect') 
     109        self.assertEquals('open', open['css_class'], 'open class incorrect') 
     110        self.assertEquals(False, open['countsToProg'], 
     111                          'open not count to prog') 
     112        self.assertEquals({'status': ['new', 'assigned', 'reopened']}, 
     113                          open['qry_args'], 'qry_args incorrect') 
     114        self.assertEquals(2, open['count'], 'open count incorrect') 
     115        self.assertEquals(67, open['percent'], 'open percent incorrect') 
     116 
     117 
     118def in_tlist(ticket, list): 
     119    return len([t for t in list if t['id'] == ticket.id]) > 0 
     120 
     121def suite(): 
     122    suite = unittest.TestSuite() 
     123    suite.addTest(unittest.makeSuite(TicketGroupStatsTestCase, 'test')) 
     124    suite.addTest(unittest.makeSuite(DefaultTicketGroupStatsProviderTestCase, 
     125                                      'test')) 
     126    return suite 
     127 
     128if __name__ == '__main__': 
     129    unittest.main(defaultTest='suite') 
     130 No newline at end of file 
  • trac/ticket/roadmap.py

    Property changes on: trac/ticket/tests/roadmap.py
    ___________________________________________________________________
    Name: svn:executable
       + *
    
     
    2626from trac.util.html import html, unescape, Markup 
    2727from trac.util.text import shorten_line, CRLF, to_unicode 
    2828from trac.ticket import Milestone, Ticket, TicketSystem 
     29from trac.ticket.query import Query 
    2930from trac.Timeline import ITimelineEventProvider 
    3031from trac.web import IRequestHandler 
    3132from trac.web.chrome import add_link, add_stylesheet, INavigationContributor 
    3233from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider 
     34from trac.config import ExtensionOption 
    3335 
     36class ITicketGroupStatsProvider(Interface): 
     37    def get_ticket_group_stats(self, ticket_ids): 
     38        """ Gather statistics on a group of tickets. 
    3439 
     40        This method returns a valid TicketGroupStats object. 
     41        """ 
     42 
     43class TicketGroupStats(object): 
     44    """Encapsulates statistics on a group of tickets.""" 
     45 
     46    def __init__(self, title, unit): 
     47        """Creates a new TicketGroupStats object. 
     48        title is the display name of this group of stats (eg 'ticket status'). 
     49        unit is the display name of the units for these stats (eg 'hour'). 
     50        """ 
     51        self.title = title 
     52        self.unit = unit 
     53        self.count = 0 
     54        self.qry_args = {} 
     55        self.intervals = [] 
     56        self.done_percent = 0 
     57        self.done_count = 0 
     58 
     59    def add_interval(self, title, count, qry_args, css_class, countsToProg=0): 
     60        """Adds a division to this stats' group's progress bar. 
     61 
     62        title is the display name (eg 'closed', 'spent effort') of this interval 
     63          that will be displayed in front of the unit name. 
     64        count is the number of units in the interval. 
     65        qry_args is a dict of extra params that will yield the subset of 
     66          tickets in this interval on a query. 
     67        css_class is the css class that will be used to display the division. 
     68        countsToProg can be set to true to make this interval count towards 
     69          overall completion of this group of tickets. 
     70        """ 
     71        self.intervals.append({ 
     72            'title': title, 
     73            'count': count, 
     74            'qry_args': qry_args, 
     75            'css_class': css_class, 
     76            'percent': None, 
     77            'countsToProg': countsToProg 
     78        }) 
     79        self.count = self.count + count 
     80        self._refresh_calcs() 
     81 
     82    def _refresh_calcs(self): 
     83        if self.count < 1: 
     84            return 
     85        total_percent = 0 
     86        self.done_percent = 0 
     87        self.done_count = 0 
     88        for interval in self.intervals: 
     89            interval['percent'] = round(float(interval['count'] /  
     90                                        float(self.count) * 100)) 
     91            total_percent = total_percent + interval['percent'] 
     92            if interval['countsToProg']: 
     93                self.done_percent += interval['percent'] 
     94                self.done_count += interval['count'] 
     95 
     96        if self.done_count and total_percent != 100: 
     97            fudge_int = [i for i in self.intervals if i['countsToProg']][0] 
     98            fudge_amt = 100 - total_percent 
     99            fudge_int['percent'] += fudge_amt 
     100            self.done_percent += fudge_amt 
     101 
     102class DefaultTicketGroupStatsProvider(Component): 
     103    implements(ITicketGroupStatsProvider) 
     104 
     105    def get_ticket_group_stats(self, ticket_ids): 
     106        total_cnt = len(ticket_ids) 
     107        if total_cnt: 
     108            cursor = self.env.get_db_cnx().cursor() 
     109            str_ids = [str(x) for x in ticket_ids] 
     110            active_cnt = cursor.execute("SELECT count(1) FROM ticket " 
     111                                    "WHERE status <> 'closed' AND id IN " 
     112                                    "(%s)" % ",".join(str_ids)) 
     113            active_cnt = 0 
     114            for cnt, in cursor: 
     115                active_cnt = cnt 
     116        else: 
     117            active_cnt = 0 
     118 
     119        closed_cnt = total_cnt - active_cnt 
     120 
     121        stat = TicketGroupStats('ticket status', 'ticket') 
     122        stat.add_interval('closed', closed_cnt, {'status': 'closed'}, 
     123                          'closed', True) 
     124        stat.add_interval('active', active_cnt, 
     125                     {'status': ['new', 'assigned', 'reopened']}, 'open', False) 
     126        return stat 
     127 
     128def get_ticket_stats(provider, tickets): 
     129    return provider.get_ticket_group_stats([t['id'] for t in tickets]) 
     130 
    35131def get_tickets_for_milestone(env, db, milestone, field='component'): 
    36132    cursor = db.cursor() 
    37133    fields = TicketSystem(env).get_ticket_fields() 
     
    47143        tickets.append({'id': tkt_id, 'status': status, field: fieldval}) 
    48144    return tickets 
    49145 
    50 def get_query_links(req, milestone, grouped_by='component', group=None): 
    51     q = {} 
    52     if not group: 
    53         q['all_tickets'] = req.href.query(milestone=milestone) 
    54         q['active_tickets'] = req.href.query( 
    55             milestone=milestone, status=('new', 'assigned', 'reopened')) 
    56         q['closed_tickets'] = req.href.query( 
    57             milestone=milestone, status='closed') 
    58     else: 
    59         q['all_tickets'] = req.href.query( 
    60             {grouped_by: group}, milestone=milestone) 
    61         q['active_tickets'] = req.href.query( 
    62             {grouped_by: group}, milestone=milestone, 
    63             status=('new', 'assigned', 'reopened')) 
    64         q['closed_tickets'] = req.href.query( 
    65             {grouped_by: group}, milestone=milestone, status='closed') 
    66     return q 
    67  
    68 def calc_ticket_stats(tickets): 
    69     total_cnt = len(tickets) 
    70     active = [ticket for ticket in tickets if ticket['status'] != 'closed'] 
    71     active_cnt = len(active) 
    72     closed_cnt = total_cnt - active_cnt 
    73  
    74     percent_active, percent_closed = 0, 0 
    75     if total_cnt > 0: 
    76         percent_active = round(float(active_cnt) / float(total_cnt) * 100) 
    77         percent_closed = round(float(closed_cnt) / float(total_cnt) * 100) 
    78         if percent_active + percent_closed > 100: 
    79             percent_closed -= 1 
    80  
    81     return { 
    82         'total_tickets': total_cnt, 
    83         'active_tickets': active_cnt, 
    84         'percent_active': percent_active, 
    85         'closed_tickets': closed_cnt, 
    86         'percent_closed': percent_closed 
    87     } 
    88  
    89146def milestone_to_hdf(env, db, req, milestone): 
    90147    safe_name = None 
    91148    if milestone.exists: 
     
    101158        hdf['late'] = milestone.is_late 
    102159    return hdf 
    103160 
     161def milestone_stat_to_hdf(env, stat, name, grouped_by='component', group=None): 
     162    def merge_cp(dict1, dict2): 
     163        cp = dict1.copy() 
     164        cp.update(dict2) 
     165        return cp 
     166 
     167    hdf = {} 
     168    base_args = {'milestone': name, grouped_by: group} 
     169    hdf['title'] = stat.title 
     170    hdf['caps_title'] = stat.title.capitalize() 
     171    hdf['count'] = stat.count 
     172    hdf['unit']= stat.unit 
     173    hdf['href'] = env.href.query(merge_cp(base_args, stat.qry_args)) 
     174    hdf['done_percent'] = stat.done_percent 
     175    hdf['done_count'] = stat.done_count 
     176    hdf['intervals'] = [] 
     177    i = 0 
     178    for i in range(len(stat.intervals)): 
     179        interval = stat.intervals[i] 
     180        int_hdf = {} 
     181        for (key,val) in interval.items(): 
     182            if key != 'qry_args': 
     183                int_hdf[key] = val 
     184        int_hdf['href'] = env.href.query( 
     185                                    merge_cp(base_args, interval['qry_args'])) 
     186        int_hdf['caps_title'] = interval['title'].capitalize() 
     187        int_hdf['last'] = i == len(stat.intervals) - 1 
     188        hdf['intervals'].append(int_hdf) 
     189        i += 1 
     190    return hdf 
     191 
    104192def _get_groups(env, db, by='component'): 
    105193    for field in TicketSystem(env).get_ticket_fields(): 
    106194        if field['name'] == by: 
     
    117205class RoadmapModule(Component): 
    118206 
    119207    implements(INavigationContributor, IPermissionRequestor, IRequestHandler) 
     208    stats_provider = ExtensionOption('trac', 'roadmap_stats', ITicketGroupStatsProvider, 
     209                            'DefaultTicketGroupStatsProvider', 
     210        """Name of the component implementing `ITicketGroupStatsProvider`,  
     211        which is used to collect statistics on groups of tickets for display 
     212        in the roadmap views.""") 
    120213 
    121214    # INavigationContributor methods 
    122215 
     
    155248            milestone_name = unescape(milestone['name']) # Kludge 
    156249            tickets = get_tickets_for_milestone(self.env, db, milestone_name, 
    157250                                                'owner') 
    158             milestone['stats'] = calc_ticket_stats(tickets) 
    159             milestone['queries'] = get_query_links(req, milestone_name) 
     251            stat = get_ticket_stats(self.stats_provider, tickets) 
     252            milestone['stats'] = milestone_stat_to_hdf(self.env, stat, milestone_name) 
    160253            milestone['tickets'] = tickets # for the iCalendar view 
    161254 
    162255        if req.args.get('format') == 'ics': 
     
    279372 
    280373    implements(INavigationContributor, IPermissionRequestor, IRequestHandler, 
    281374               ITimelineEventProvider, IWikiSyntaxProvider) 
     375  
     376    stats_provider = ExtensionOption('trac', 'milestone_stats', ITicketGroupStatsProvider, 
     377                            'DefaultTicketGroupStatsProvider', 
     378        """Name of the component implementing `ITicketGroupStatsProvider`,  
     379        which is used to collect statistics on groups of tickets for display 
     380        in the milestone views.""") 
     381     
    282382 
    283383    # INavigationContributor methods 
    284384 
     
    467567            by = req.args.get('by', available_groups[0]['name']) 
    468568 
    469569        tickets = get_tickets_for_milestone(self.env, db, milestone.name, by) 
    470         data['stats'] = calc_ticket_stats(tickets) 
    471         data['stats']['available_groups'] = available_groups 
    472         data['stats']['grouped_by'] = by 
    473         data['queries'] = get_query_links(req, milestone.name) 
    474570 
     571        stat = get_ticket_stats(self.stats_provider, tickets) 
     572        data['stats'] = {'available_groups': available_groups,  
     573                         'grouped_by': by} 
     574        data['stats'].update(milestone_stat_to_hdf(self.env, stat, 
     575                                                  milestone.name)) 
     576 
    475577        data['stats']['groups'] = [] 
    476578        groups = _get_groups(self.env, db, by) 
    477         max_percent_total = 0 
     579        max_count = 0 
     580        group_stats = [] 
     581 
    478582        for group in groups: 
    479583            group_tickets = [t for t in tickets if t[by] == group] 
    480584            if not group_tickets: 
    481585                continue 
    482             data['stats']['groups'].append({'name': group}) 
    483             percent_total = 0 
    484             if len(tickets) > 0: 
    485                 percent_total = float(len(group_tickets)) / float(len(tickets)) 
    486                 if percent_total > max_percent_total: 
    487                     max_percent_total = percent_total 
    488             data['stats']['groups'][-1]['percent_total'] = percent_total * 100 
    489             data['stats']['groups'][-1]['stats'] = calc_ticket_stats(group_tickets) 
    490             data['stats']['groups'][-1]['queries'] = get_query_links(req, milestone.name, by, group) 
    491         data['stats']['max_percent_total'] = max_percent_total * 100 
    492586 
     587            gstat = get_ticket_stats(self.stats_provider, group_tickets) 
     588             
     589            gs_dict = {'name': group}  
     590            gs_dict['stats'] = milestone_stat_to_hdf(self.env, gstat, 
     591                                                 milestone.name, by, group) 
     592                 
     593            if gstat.count > max_count: 
     594                max_count = gstat.count 
     595              
     596            group_stats.append(gstat)                 
     597            data['stats']['groups'].append(gs_dict) 
     598         
     599        grp_no = 0 
     600        for gstat in group_stats: 
     601            d = data['stats']['groups'][grp_no] 
     602            d['percent_of_max_total'] = (float(gstat.count) / 
     603                                        float(max_count) * 100) 
     604            grp_no +=1 
     605 
    493606        return 'milestone_view.html', data, None 
    494607 
    495608    # IWikiSyntaxProvider methods 
  • templates/milestone_view.html

     
    3232            No date set 
    3333          </p> 
    3434        </py:choose> 
    35         <py:if test="stats.total_tickets"> 
     35        <py:if test="stats.count"> 
    3636          <table class="progress"> 
    37             <td class="closed" style="width: ${stats.percent_closed}%"> 
    38               <a href="$queries.closed_tickets" 
    39                  title="${stats.active_tickets} of ${stats.total_tickets} ticket${stats.total_tickets != 1 and 's' or ''} closed"></a> 
     37            <py:for each="interval in stats.intervals"> 
     38            <td class="$interval.css_class" style="width: ${interval.percent}%"> 
     39              <a href="$interval.href" 
     40                 title="${interval.count} of ${stats.count} ${stats.unit}${stats.count != 1 and 's' or ''} ${interval.title}"></a> 
    4041            </td> 
    41             <td class="open" style="width: ${stats.percent_active}"> 
    42               <a href="$queries.active_tickets" 
    43                  title="${stats.active_tickets} of ${stats.total_tickets} ticket${stats.total_tickets != 1 and 's' or ''} active"></a> 
    44             </td> 
     42            </py:for> 
    4543          </table> 
    46           <p class="percent">${stats.percent_closed}%</p> 
     44          <p class="percent">${stats.done_percent}%</p> 
    4745          <dl> 
    48             <dt>Closed tickets:</dt> 
    49             <dd><a href="$queries.closed_tickets">${stats.closed_tickets}</a></dd> 
    50             <dt>Active tickets:</dt> 
    51             <dd><a href="$queries.active_tickets">${stats.active_tickets}</a></dd> 
     46          <py:for each="interval in stats.intervals"> 
     47            <dt>${interval.caps_title} ${stats.unit}s:</dt> 
     48            <dd><a href="$interval.href">${interval.count}</a></dd> 
     49          </py:for> 
    5250          </dl> 
    5351        </py:if> 
    5452      </div> 
     
    5654      <form py:if="stats.available_groups" id="stats" action=""> 
    5755        <fieldset> 
    5856          <legend> 
    59             <label for="by">Ticket status by</label> 
     57            <label for="by">${stats.caps_title} by</label> 
    6058            <select id="by" name="by" onchange="this.form.submit()"> 
    6159              <option py:for="group in stats.available_groups" 
    6260                      value="${group.name}" py:content="group.label" 
     
    6765          <table summary="Shows the milestone completion status grouped by ${stats.grouped_by}"> 
    6866            <tr py:for="group in stats.groups"> 
    6967              <th scope="row"> 
    70                 <a href="${group.queries.all_tickets}">${group.name}</a> 
     68                <a href="${group.stats.href}">${group.name}</a> 
    7169              </th> 
    7270              <td style="white-space: nowrap"> 
    73                 <table class="progress" style="width: ${group.percent_total * 80 / stats.max_percent_total}%"> 
    74                   <td class="closed" style="width: ${group.stats.percent_closed}%"> 
    75                     <a href="$queries.closed_tickets" 
    76                        title="${group.stats.active_tickets} of ${group.stats.total_tickets} ticket${group.stats.total_tickets != 1 and 's' or ''} closed"></a> 
     71                <table class="progress" style="width: ${group.percent_of_max_total * 0.8}%"> 
     72                <py:for each="interval in group.stats.intervals"> 
     73                  <td class="$interval.css_class" style="width: ${interval.percent}%"> 
     74                    <a href="$interval.href" 
     75                       title="${interval.count} of ${group.stats.count} ${group.stats.unit}${group.stats.count != 1 and 's' or ''} ${interval.title}"></a> 
    7776                  </td> 
    78                   <td class="open" style="width: ${group.stats.percent_active}"> 
    79                     <a href="$group.queries.active_tickets" 
    80                        title="${group.stats.active_tickets} of ${group.stats.total_tickets} ticket${group.stats.total_tickets != 1 and 's' or ''} active"></a> 
    81                   </td> 
     77                </py:for> 
    8278                </table> 
    83                 <p class="percent">${group.stats.closed_tickets} / ${group.stats.total_tickets}</p> 
     79                <p class="percent">${group.stats.done_count} / ${group.stats.count}</p> 
    8480              </td> 
    8581            </tr> 
    8682          </table> 
  • templates/roadmap.html

     
    1212  </head> 
    1313 
    1414  <body> 
     15   
    1516    <div id="ctxtnav" class="nav"></div> 
    1617 
    1718    <div id="content" class="roadmap"> 
    1819      <h1>Roadmap</h1> 
    19  
     20       
    2021      <form id="prefs" method="get" action=""> 
    2122        <div> 
    2223          <input type="checkbox" id="showall" name="show" value="all" 
     
    4041                 class="date">Due in ${pretty_timedelta(milestone.due + timedelta(days=1))}</p> 
    4142              <p py:otherwise="" class="date">No date set</p> 
    4243            </py:choose> 
    43             <py:if test="milestone.stats.total_tickets" 
     44            <py:if test="milestone.stats.count" 
    4445                   py:with="stats = milestone.stats; queries = milestone.queries"> 
    4546              <table class="progress"> 
    46                 <td class="closed" style="width: ${stats.percent_closed}%"> 
    47                   <a href="$queries.closed_tickets" 
    48                      title="${stats.active_tickets} of ${stats.total_tickets} ticket${stats.total_tickets != 1 and 's' or ''} closed"></a> 
     47              <py:for each="interval in stats.intervals"> 
     48                <td class="${interval.css_class}" style="width: ${interval.percent}%"> 
     49                  <a href="$interval.href" 
     50                     title="${interval.count} of ${stats.count} ${stats.unit}${stats.count != 1 and 's' or ''} ${interval.title}"></a> 
    4951                </td> 
    50                 <td class="open" style="width: ${stats.percent_active}"> 
    51                   <a href="$queries.active_tickets" 
    52                      title="${stats.active_tickets} of ${stats.total_tickets} ticket${stats.total_tickets != 1 and 's' or ''} active"></a> 
    53                 </td> 
     52              </py:for> 
    5453              </table> 
    55               <p class="percent">${stats.percent_closed}%</p> 
     54              <p class="percent">${stats.done_percent}%</p> 
    5655              <dl> 
    57                 <dt>Closed tickets:</dt> 
    58                 <dd><a href="$queries.closed_tickets">${stats.closed_tickets}</a></dd> 
    59                 <dt>Active tickets:</dt> 
    60                 <dd><a href="$queries.active_tickets">${stats.active_tickets}</a></dd> 
     56              <py:for each="interval in stats.intervals"> 
     57                <dt>${interval.caps_title} ${stats.unit}s:</dt> 
     58                <dd><a href="$interval.href">${interval.count}</a></dd> 
     59              </py:for> 
    6160              </dl> 
    6261            </py:if> 
    6362          </div>