Index: trac/ticket/roadmap.py
===================================================================
--- trac/ticket/roadmap.py	(revision 3301)
+++ trac/ticket/roadmap.py	(working copy)
@@ -30,20 +30,38 @@ from trac.web import IRequestHandler
 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
 from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider
 
+def parse_time(pattern):
+    match = re.search(r'(-?[0-9]*(\.[0-9]*)?).?(m|min|h)', str(pattern))
+    if match:
+        value = match.group(1)
+        unit = match.group(3)
+
+        if (unit == 'm' or unit == 'min'):
+            value = float(value) / 60.0
+        return float(value)
+    return 0.0
 
-def get_tickets_for_milestone(env, db, milestone, field='component'):
+def get_tickets_for_milestone(env, db, milestone, fields=[ 'component' ]):
     cursor = db.cursor()
-    fields = TicketSystem(env).get_ticket_fields()
-    if field in [f['name'] for f in fields if not f.get('custom')]:
-        cursor.execute("SELECT id,status,%s FROM ticket WHERE milestone=%%s "
-                       "ORDER BY %s" % (field, field), (milestone,))
-    else:
-        cursor.execute("SELECT id,status,value FROM ticket LEFT OUTER "
-                       "JOIN ticket_custom ON (id=ticket AND name=%s) "
-                       "WHERE milestone=%s ORDER BY value", (field, milestone))
-    tickets = []
-    for tkt_id, status, fieldval in cursor:
-        tickets.append({'id': tkt_id, 'status': status, field: fieldval})
+    stdfields = TicketSystem(env).get_ticket_fields()
+    sql = "SELECT DISTINCT "
+    for field in fields:
+        if field not in [f['name'] for f in stdfields if not f.get('custom')]:
+            sql += "%s.value AS %s, " % (field, field)
+        else:
+            sql += "ticket.%s AS %s, " % (field, field)
+    sql += "ticket.id AS id, ticket.status AS status FROM ticket "
+    for field in fields:
+        if field not in [f['name'] for f in stdfields if not f.get('custom')]:
+            sql += "LEFT OUTER JOIN ticket_custom %s ON (ticket.id=%s.ticket AND %s.name='%s') " % (field, field, field, field)
+    sql += "WHERE milestone='%s' ORDER BY %s" % (milestone, field)
+
+    cursor.execute(sql)
+    
+    if cursor.description is None:
+        return []
+    cols = [col[0] for col in cursor.description]
+    tickets = [dict(zip(cols, vals)) for vals in cursor]
     return tickets
 
 def get_query_links(env, milestone, grouped_by='component', group=None):
@@ -77,12 +95,40 @@ def calc_ticket_stats(tickets):
         if percent_active + percent_closed > 100:
             percent_closed -= 1
 
+    estimated_work = 0.0
+    spent_work = 0.0
+    remaining_work = 0.0
+    for ticket in tickets:
+        if ticket['status'] != 'closed':
+            if (ticket.has_key('tt_remaining')):
+                remaining_work += parse_time(ticket['tt_remaining'])
+            elif (ticket.has_key('tt_estimated')):
+                remaining_work += parse_time(ticket['tt_estimated'])
+        if (ticket.has_key('tt_spent')):
+            spent_work += parse_time(ticket['tt_spent'])
+        if (ticket.has_key('tt_estimated')):
+            estimated_work += parse_time(ticket['tt_estimated'])
+
+    work_percent_complete = 0
+    if spent_work > 0:
+        work_percent_complete = float(spent_work) / float(spent_work + remaining_work) * 100
+    work_percent_remaining = 100 - work_percent_complete
+
+    has_stats = 1; #estimated_work > 0.0 or spent_work > 0.0 or remaining_work > 0.0
+
     return {
         'total_tickets': total_cnt,
         'active_tickets': active_cnt,
         'percent_active': percent_active,
         'closed_tickets': closed_cnt,
-        'percent_closed': percent_closed
+        'percent_closed': percent_closed,
+        'estimated_work' : estimated_work,
+        'estimated_diff' : estimated_work - (spent_work + remaining_work),
+        'spent_work' : spent_work,
+        'remaining_work' : remaining_work,
+        'work_percent_complete': work_percent_complete,
+        'work_percent_remaining': work_percent_remaining,
+		'has_stats': has_stats,
     }
 
 def milestone_to_hdf(env, db, req, milestone):
@@ -160,7 +206,7 @@ class RoadmapModule(Component):
             milestone_name = unescape(milestone['name']) # Kludge
             prefix = 'roadmap.milestones.%d.' % idx
             tickets = get_tickets_for_milestone(self.env, db, milestone_name,
-                                                'owner')
+                                                [ 'owner', 'tt_estimated', 'tt_remaining', 'tt_spent' ])
             req.hdf[prefix + 'stats'] = calc_ticket_stats(tickets)
             for k, v in get_query_links(self.env, milestone_name).items():
                 req.hdf[prefix + 'queries.' + k] = v
@@ -467,8 +513,9 @@ class MilestoneModule(Component):
             by = req.args.get('by', available_groups[0]['name'])
         req.hdf['milestone.stats.grouped_by'] = by
 
-        tickets = get_tickets_for_milestone(self.env, db, milestone.name, by)
+        tickets = get_tickets_for_milestone(self.env, db, milestone.name, [ by, 'tt_estimated', 'tt_remaining', 'tt_spent' ])
         stats = calc_ticket_stats(tickets)
+
         req.hdf['milestone.stats'] = stats
         for key, value in get_query_links(self.env, milestone.name).items():
             req.hdf['milestone.queries.' + key] = value
Index: templates/roadmap.cs
===================================================================
--- templates/roadmap.cs	(revision 3301)
+++ templates/roadmap.cs	(working copy)
@@ -63,6 +63,31 @@
          var:stats.active_tickets ?></a></dd>
       </dl><?cs
      /if ?><?cs
+     if:#stats.has_stats ?>
+      <div style="margin: 0; height: 1px;"></div>
+      <table class="progress">
+       <tr><?cs
+        if:#stats.work_percent_complete > #0.0 ?>
+         <td class="closed" style="width: <?cs 
+           var:#stats.work_percent_complete ?>%">&nbsp;</td><?cs
+        /if ?><?cs
+        if:#stats.work_percent_remaining > #0.0 ?>
+         <td class="open" style="width: <?cs
+           var:#stats.work_percent_remaining ?>%">&nbsp;</td><?cs
+        /if ?>
+       </tr>
+      </table>
+      <p class="percent"><?cs var:#stats.work_percent_complete ?>%</p>     
+      <dl>
+       <dt>Estimated work:</dt>
+       <dd><?cs var:stats.estimated_work ?> h 
+         (<?cs var:stats.estimated_diff ?> h)</dd>
+       <dt>Spent work:</dt>
+       <dd><?cs var:stats.spent_work ?> h</dd>
+       <dt>Remaining work:</dt>
+       <dd><?cs var:stats.remaining_work ?> h</dd>
+      </dl><?cs
+     /if ?><?cs
     /with ?>
    </div>
    <div class="description"><?cs var:milestone.description ?></div>
Index: templates/milestone.cs
===================================================================
--- templates/milestone.cs	(revision 3301)
+++ templates/milestone.cs	(working copy)
@@ -145,6 +145,31 @@
         var:stats.active_tickets ?></a></dd>
      </dl><?cs
     /if ?><?cs
+    if:#stats.has_stats ?>
+     <div style="margin: 0; height: 1px;"></div>
+     <table class="progress">
+      <tr><?cs
+        if:#stats.work_percent_complete > #0.0 ?>
+         <td class="closed" style="width: <?cs 
+           var:#stats.work_percent_complete ?>%">&nbsp;</td><?cs
+        /if ?><?cs
+        if:#stats.work_percent_remaining > #0.0 ?>
+         <td class="open" style="width: <?cs
+           var:#stats.work_percent_remaining ?>%">&nbsp;</td><?cs
+        /if ?>
+      </tr>
+     </table>
+     <p class="percent"><?cs var:#stats.work_percent_complete ?>%</p>     
+     <dl>
+      <dt>Estimated work:</dt>
+      <dd><?cs var:stats.estimated_work ?> h
+        (<?cs var:stats.estimated_diff ?> h)</dd>
+      <dt>Spent work:</dt>
+      <dd><?cs var:stats.spent_work ?> h</dd>
+      <dt>Remaining work:</dt>
+      <dd><?cs var:stats.remaining_work ?> h</dd>
+     </dl><?cs
+    /if ?><?cs
    /with ?>
   </div>
   <form id="stats" action="" method="get">
Index: contrib/trac-post-commit-hook
===================================================================
--- contrib/trac-post-commit-hook	(revision 3301)
+++ contrib/trac-post-commit-hook	(working copy)
@@ -76,6 +76,7 @@ import time 
 from trac.env import open_environment
 from trac.Notify import TicketNotifyEmail
 from trac.ticket import Ticket
+from trac.ticket.roadmap import parse_time
 from trac.web.href import Href
 
 try:
@@ -115,6 +116,8 @@ else:
 
 commandPattern = re.compile(leftEnv + r'(?P<action>[A-Za-z]*).?(?P<ticket>#[0-9]+(?:(?:[, &]*|[ ]?and[ ]?)#[0-9]+)*)' + rghtEnv)
 ticketPattern = re.compile(r'#([0-9]*)')
+spentPattern = re.compile(r'(spent|sp) ([0-9]*)(m|min|h)')
+remainingPattern = re.compile(r'(remaining|rem) ([0-9]*)(m|min|h)')
 
 class CommitHook:
     _supported_cmds = {'close':      '_cmdClose',
@@ -141,16 +144,43 @@ class CommitHook:
         self.env.href = Href(url)
         self.env.abs_href = Href(url)
 
+        self.time_spent = -1
+        groups = spentPattern.findall(msg) 
+        for cmd, value, unit in groups:
+			self.time_spent = parse_time(value + unit)
+				
+        self.time_remaining = -1
+        groups = remainingPattern.findall(msg) 
+        for cmd, value, unit in groups:
+			self.time_remaining = parse_time(value + unit)
+
         cmdGroups = commandPattern.findall(msg) 
         for cmd, tkts in cmdGroups:
             if CommitHook._supported_cmds.has_key(cmd.lower()):
                 func = getattr(self, CommitHook._supported_cmds[cmd.lower()])
                 func(ticketPattern.findall(tkts))
 
+    def _setCustomFields(self, ticket):
+		if (self.time_spent != -1):
+			if (ticket.values.has_key('tt_spent')):
+				ticket['tt_spent'] = str(parse_time(ticket['tt_spent']) + self.time_spent) + 'h'
+			else:
+				ticket['tt_spent'] = str(self.time_spent) + 'h'
+
+			if (ticket.values.has_key('tt_remaining')):
+				ticket['tt_remaining'] = str(parse_time(ticket['tt_remaining']) - self.time_spent) + 'h'
+			else:
+				if (ticket.values.has_key('tt_planned')):
+					ticket['tt_remaining'] = str(parse_time(ticket['tt_planned']) - self.time_spent) + 'h'
+
+		if (self.time_remaining != -1):
+			ticket['tt_remaining'] = str(self.time_remaining) + 'h'
+
     def _cmdClose(self, tickets):
         for tkt_id in tickets:
             try:
                 ticket = Ticket(self.env, tkt_id)
+                self._setCustomFields(ticket)
                 ticket['status'] = 'closed'
                 ticket['resolution'] = 'fixed'
                 ticket.save_changes(self.author, self.msg, self.now)
@@ -164,6 +194,7 @@ class CommitHook:
         for tkt_id in tickets: 
             try:
                 ticket = Ticket(self.env, tkt_id)
+                self._setCustomFields(ticket)
                 ticket.save_changes(self.author, self.msg, self.now)
                 tn = TicketNotifyEmail(self.env)
                 tn.notify(ticket, newticket=0, modtime=self.now)
