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,42 @@ 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 + remaining_work <= 0:
+        work_percent_complete = 100
+    elif spent_work > 0:
+        work_percent_complete = float(spent_work) / float(spent_work + remaining_work) * 100
+    work_percent_remaining = 100 - work_percent_complete
+
+    has_stats = 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 +208,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 +515,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
@@ -476,6 +525,8 @@ class MilestoneModule(Component):
         groups = _get_groups(self.env, db, by)
         group_no = 0
         max_percent_total = 0
+        max_percent_total_time = 0
+        tickets_stats = stats
         for group in groups:
             group_tickets = [t for t in tickets if t[by] == group]
             if not group_tickets:
@@ -483,18 +534,30 @@ class MilestoneModule(Component):
             prefix = 'milestone.stats.groups.%s' % group_no
             req.hdf['%s.name' % prefix] = group
             percent_total = 0
-            if len(tickets) > 0:
+            percent_remaining_time = 0
+
+            # calculate stats for this group
+            stats = calc_ticket_stats(group_tickets)
+            req.hdf[prefix] = stats
+
+            # calculate the percentage of the tickets in this group
                 percent_total = float(len(group_tickets)) / float(len(tickets))
                 if percent_total > max_percent_total:
                     max_percent_total = percent_total
+
+            # calculate the percenatge of the time in this group
+            percent_total_time = float(stats['estimated_work']) / float(tickets_stats['estimated_work'] )
+            if percent_total_time > max_percent_total_time:
+                max_percent_total_time = percent_total_time
+
             req.hdf['%s.percent_total' % prefix] = percent_total * 100
-            stats = calc_ticket_stats(group_tickets)
-            req.hdf[prefix] = stats
+            req.hdf['%s.percent_total_time' % prefix] = percent_total_time * 100
             for key, value in get_query_links(self.env, milestone.name,
                                               by, group).items():
                 req.hdf['%s.queries.%s' % (prefix, key)] = value
             group_no += 1
         req.hdf['milestone.stats.max_percent_total'] = max_percent_total * 100
+        req.hdf['milestone.stats.max_percent_total_time'] = max_percent_total_time * 100
 
     # IWikiSyntaxProvider methods
 
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">
@@ -160,7 +185,9 @@
      <noscript><input type="submit" value="Update" /></noscript>
     </legend>
     <table summary="Shows the milestone completion status grouped by <?cs
-      var:milestone.stats.grouped_by ?>"><?cs
+      var:milestone.stats.grouped_by ?>">
+
+<?cs
      each:group = milestone.stats.groups ?>
       <tr>
        <th scope="row"><a href="<?cs
@@ -190,6 +217,36 @@
        <?cs /if ?></td>
       </tr><?cs
      /each ?>
+
+<?cs
+     each:group = milestone.stats.groups ?>
+      <tr>
+       <th scope="row"><a href="<?cs
+         var:group.queries.all_tickets ?>"><?cs var:group.name ?></a></th>
+       <td style="white-space: nowrap"><?cs if:#group.estimated_work ?>
+        <table class="progress" style="width: <?cs
+          var:#group.percent_total_time * #80 / #milestone.stats.max_percent_total_time ?>%">
+         <tr>
+          <td class="closed" style="width: <?cs
+            var:#group.work_percent_complete ?>%"><a href="<?cs
+            var:group.queries.spent_work ?>" title="<?cs
+           var:group.spent_work ?> of <?cs
+           var:group.estimated_work ?> hours completed"> </a>
+          </td>
+          <td class="open" style="width: <?cs
+            var:#group.work_percent_remaining ?>%"><a href="<?cs
+            var:group.queries.remaning_work ?>" title="<?cs
+           var:group.remaning_work ?> of <?cs
+           var:group.estimated_work ?> hours completed"> </a>
+          </td>
+         </tr>
+        </table>
+        <p class="percent"><?cs var:group.spent_work ?>/<?cs
+         var:group.estimated_work ?> (h)</p>
+       <?cs /if ?></td>
+      </tr><?cs
+     /each ?>
+
     </table><?cs /if ?>
    </fieldset>
   </form>
Index: contrib/trac-post-commit-hook
===================================================================
--- contrib/trac-post-commit-hook	(revision 3301)
+++ contrib/trac-post-commit-hook	(working copy)
@@ -68,6 +68,28 @@
 #
 # This will close #10 and #12, and add a note to #12.
 
+
+#
+# Changes for Time Tracking (magnus@devdep.com) 
+#
+# "Blah refs #12 (1h)" will add 1h to the spent time for issue #12
+# "Blah refs #12 (spent 1h)" will add 1h to the spent time for issue #12
+# "Blah refs #12 (spent 1h, rem 2h) will add 1h to the spent time for issue #12 and will change remaing to 2h
+#
+# As above it is possible to use complicated messages:
+#
+# "Changed blah and foo to do this or that. Fixes #10 (1h) and #12 (2h), and refs #13 (30m, rem 2h)."
+#
+# This will, as above, close #10 and #12, and add a note to #13 and also add 1h spent time to #10,
+# add 2h spent time to #12 and add 30m spent time to #13 and finally update remaing time to 2h for #13.
+#
+# Note that:
+#     you must have 'spent' to use 'rem'
+#     spent, sp or simply nothing may be used for spent
+#     rem or remaing may be used
+#     ' ', ',', '&' or 'and' may be used between spent and rem
+#
+
 import re
 import os
 import sys
@@ -76,6 +98,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:
@@ -113,8 +136,9 @@ else:
     leftEnv = ''
     rghtEnv = ''
 
-commandPattern = re.compile(leftEnv + r'(?P<action>[A-Za-z]*).?(?P<ticket>#[0-9]+(?:(?:[, &]*|[ ]?and[ ]?)#[0-9]+)*)' + rghtEnv)
-ticketPattern = re.compile(r'#([0-9]*)')
+commandPattern = re.compile(leftEnv + r'(?P<action>[A-Za-z]*).?(?P<ticket>#[0-9]+.?(?:\(.+\))?.?(?:(?:[, &]*|[ ]?and[ ]?)#[0-9]+.?(?:\(.+\))?.?)*)' + rghtEnv)
+ticketSplitPattern = re.compile(r'(#[0-9]*.?(?:\((?:(?:(?:spent|sp) )?[0-9]*)(?:m|min|h)(?: *(?:[, &]| and ) *(?:remaining|rem) [0-9]*(?:m|min|h))?\))?)')
+ticketPattern = re.compile(r'(?:#([0-9]*).?(?:\((?:(?:(?:spent|sp) )?([0-9]*))(m|min|h)(?: *(?:[, &]| and ) *(?:remaining|rem) ([0-9]*)(m|min|h))?\))?)')
 
 class CommitHook:
     _supported_cmds = {'close':      '_cmdClose',
@@ -143,14 +167,36 @@ class CommitHook:
 
         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))
+            tickets = ticketSplitPattern.findall(tkts)
+            for ticket in tickets:
+                ticketAndTimes = ticketPattern.findall(ticket)
+                func(ticketAndTimes)
+
+    def _setCustomFields(self, ticket, spent, spent_t, rem, rem_t):
+        if (spent != ''):
+            spentTime = parse_time(spent + spent_t)
+			if (ticket.values.has_key('tt_spent')):
+                ticket['tt_spent'] = str(parse_time(ticket['tt_spent']) + spentTime) + 'h'
+			else:
+                ticket['tt_spent'] = str(spentTime) + 'h'
+
+			if (ticket.values.has_key('tt_remaining')):
+                    ticket['tt_remaining'] = str(parse_time(ticket['tt_remaining']) - spentTime) + 'h'
+			else:
+				if (ticket.values.has_key('tt_planned')):
+                        ticket['tt_remaining'] = str(parse_time(ticket['tt_planned']) - spentTime) + 'h'
+                        
+        if (rem != ''):
+            remTime = parse_time(rem + rem_t)
+            ticket['tt_remaining'] = str(remTime) + 'h'
+
 
     def _cmdClose(self, tickets):
-        for tkt_id in tickets:
+        for tkt_id, spent, spent_t, rem, rem_t in tickets:
             try:
-                ticket = Ticket(self.env, tkt_id)
+                ticket = Ticket(self.env, int(tkt_id))
+                self._setCustomFields(ticket, spent, spent_t, rem, rem_t)
                 ticket['status'] = 'closed'
                 ticket['resolution'] = 'fixed'
                 ticket.save_changes(self.author, self.msg, self.now)
@@ -161,9 +207,10 @@ class CommitHook:
                                    'ID %s: %s' % (tkt_id, e)
 
     def _cmdRefs(self, tickets):
-        for tkt_id in tickets: 
+        for tkt_id, spent, spent_t, rem, rem_t in tickets:
             try:
-                ticket = Ticket(self.env, tkt_id)
+                ticket = Ticket(self.env, int(tkt_id))
+                self._setCustomFields(ticket, spent, spent_t, rem, rem_t)
                 ticket.save_changes(self.author, self.msg, self.now)
                 tn = TicketNotifyEmail(self.env)
                 tn.notify(ticket, newticket=0, modtime=self.now)
