Index: htdocs/css/roadmap.css
===================================================================
--- htdocs/css/roadmap.css	(revision 2449)
+++ htdocs/css/roadmap.css	(working copy)
@@ -1,15 +1,15 @@
 /* General styles for the progress bars */
 div.progress { border: 1px solid #d7d7d7; float: left }
-div.progress :link, div.progress :visited {
+div.progress :link, div.progress :visited, div.progress div {
  background: #fff;
  border: none;
  display: block;
  float: left;
  height: 1.2em;
 }
-div.progress :link:hover, div.progress :visited:hover { background: #fff }
-div.progress .closed:link, div.progress .closed:visited { background: #bae0ba }
-p.percent { font-size: 10px; line-height: 2.4em; margin: 0.9em 0 0 }
+div.progress :link:hover, div.progress :visited:hover, div.progress div { background: #fff }
+div.progress .closed:link, div.progress .closed:visited, div.progress div.closed { background: #bae0ba }
+p.percent { font-size: 10px; line-height: 2.4em; margin: 0.9em 0 0; }
 
 /* Styles for the roadmap view */
 ul.milestones { margin: 2em 0 0; padding: 0 }
Index: htdocs/css/ticket.css
===================================================================
--- htdocs/css/ticket.css	(revision 2449)
+++ htdocs/css/ticket.css	(working copy)
@@ -66,12 +66,22 @@
  width: 45%;
 }
 #properties .col2 { margin-left: 40% }
-#properties .main label, #properties .col1 label, #properties .col2 label {
- float:left;
- width: 7em;
+#properties .main label, #properties .col1 label, #properties .col2 label,
+#properties .custom .field label { 
+ float: left;
+ width: 9em;
  text-align: right;
  margin-right: .5em;
 }
+
+#properties .custom .field fieldset.radio label { 
+ width: auto;
+}
+
+#properties .custom .field fieldset.custom_radio { 
+ border: none;
+}
+
 #properties .custom {
  clear: left;
  border-top: 1px dotted #d7d7d7;
Index: trac/ticket/roadmap.py
===================================================================
--- trac/ticket/roadmap.py	(revision 2449)
+++ trac/ticket/roadmap.py	(working copy)
@@ -29,479 +29,533 @@
 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
 from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider
 
-def get_tickets_for_milestone(env, db, milestone, field='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})
-    return tickets
+def _toHour(value, unit):
+    if (unit.lower() == 'm'):
+        value = float(value) / 60
+    return float(value)
 
+def _parseTime(value):
+    match = re.search(r'([0-9]*)(m|h)', str(value))
+    if match:
+        value = match.group(1)
+        unit = match.group(2)
+        return _toHour(value, unit)
+    return 0
+
+def get_tickets_for_milestone(env, db, milestone, fields=[ 'component' ]):
+	cursor = db.cursor()
+	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)
+
+	env.log.warn("Executing '%s'" % (sql))
+	cursor.execute(sql)
+
+	tickets = []
+	while 1:
+		row = cursor.fetchone()
+		if not row:
+			break
+		ticket = {
+			'id': int(row['id']),
+			'status': row['status'],
+		}
+		for field in fields:
+			ticket[field] = row[field]
+		tickets.append(ticket)
+	return tickets
+
 def get_query_links(env, milestone, grouped_by='component', group=None):
-    q = {}
-    if not group:
-        q['all_tickets'] = env.href.query(milestone=milestone)
-        q['active_tickets'] = env.href.query(milestone=milestone,
-                                             status=('new', 'assigned', 'reopened'))
-        q['closed_tickets'] = env.href.query(milestone=milestone, status='closed')
-    else:
-        q['all_tickets'] = env.href.query({grouped_by: group},
-                                          milestone=milestone)
-        q['active_tickets'] = env.href.query({grouped_by: group},
-                                             milestone=milestone,
-                                             status=('new', 'assigned', 'reopened'))
-        q['closed_tickets'] = env.href.query({grouped_by: group},
-                                             milestone=milestone,
-                                             status='closed')
-    return q
+	q = {}
+	if not group:
+		q['all_tickets'] = env.href.query(milestone=milestone)
+		q['active_tickets'] = env.href.query(milestone=milestone,
+											 status=('new', 'assigned', 'reopened'))
+		q['closed_tickets'] = env.href.query(milestone=milestone, status='closed')
+	else:
+		q['all_tickets'] = env.href.query({grouped_by: group},
+										  milestone=milestone)
+		q['active_tickets'] = env.href.query({grouped_by: group},
+											 milestone=milestone,
+											 status=('new', 'assigned', 'reopened'))
+		q['closed_tickets'] = env.href.query({grouped_by: group},
+											 milestone=milestone,
+											 status='closed')
+	return q
 
 def calc_ticket_stats(tickets):
-    total_cnt = len(tickets)
-    active = [ticket for ticket in tickets if ticket['status'] != 'closed']
-    active_cnt = len(active)
-    closed_cnt = total_cnt - active_cnt
+	total_cnt = len(tickets)
+	active = [ticket for ticket in tickets if ticket['status'] != 'closed']
+	active_cnt = len(active)
+	closed_cnt = total_cnt - active_cnt
 
-    percent_active, percent_closed = 0, 0
-    if total_cnt > 0:
-        percent_active = round(float(active_cnt) / float(total_cnt) * 100)
-        percent_closed = round(float(closed_cnt) / float(total_cnt) * 100)
-        if percent_active + percent_closed > 100:
-            percent_closed -= 1
+	percent_active, percent_closed = 0, 0
+	if total_cnt > 0:
+		percent_active = round(float(active_cnt) / float(total_cnt) * 100)
+		percent_closed = round(float(closed_cnt) / float(total_cnt) * 100)
+		if percent_active + percent_closed > 100:
+			percent_closed -= 1
 
-    return {
-        'total_tickets': total_cnt,
-        'active_tickets': active_cnt,
-        'percent_active': percent_active,
-        'closed_tickets': closed_cnt,
-        'percent_closed': percent_closed
-    }
+	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 += float(_parseTime(ticket['tt_remaining']))
+			elif (ticket.has_key('tt_estimated')):
+				remaining_work += float(_parseTime(ticket['tt_estimated']))
+		if (ticket.has_key('tt_spent')):
+			spent_work += float(_parseTime(ticket['tt_spent']))
+		if (ticket.has_key('tt_estimated')):
+			estimated_work += float(_parseTime(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
+
+	return {
+		'total_tickets': total_cnt,
+		'active_tickets': active_cnt,
+		'percent_active': percent_active,
+		'closed_tickets': closed_cnt,
+		'percent_closed': percent_closed,
+		'estimated_work' : estimated_work,
+		'spent_work' : spent_work,
+		'remaining_work' : remaining_work,
+		'work_percent_complete': work_percent_complete,
+		'work_percent_remaining': work_percent_remaining
+	}
+
 def milestone_to_hdf(env, db, req, milestone):
-    safe_name = None
-    if milestone.exists:
-        safe_name = milestone.name.replace('/', '%2F')
-    hdf = {'name': escape(milestone.name),
-           'href': escape(env.href.milestone(safe_name))}
-    if milestone.description:
-        hdf['description_source'] = escape(milestone.description)
-        hdf['description'] = wiki_to_html(milestone.description, env, req, db)
-    if milestone.due:
-        hdf['due'] = milestone.due
-        hdf['due_date'] = format_date(milestone.due)
-        hdf['due_delta'] = pretty_timedelta(milestone.due)
-        hdf['late'] = milestone.is_late
-    if milestone.completed:
-        hdf['completed'] = milestone.completed
-        hdf['completed_date'] = format_datetime(milestone.completed)
-        hdf['completed_delta'] = pretty_timedelta(milestone.completed)
-    return hdf
+	safe_name = None
+	if milestone.exists:
+		safe_name = milestone.name.replace('/', '%2F')
+	hdf = {'name': escape(milestone.name),
+		   'href': escape(env.href.milestone(safe_name))}
+	if milestone.description:
+		hdf['description_source'] = escape(milestone.description)
+		hdf['description'] = wiki_to_html(milestone.description, env, req, db)
+	if milestone.due:
+		hdf['due'] = milestone.due
+		hdf['due_date'] = format_date(milestone.due)
+		hdf['due_delta'] = pretty_timedelta(milestone.due)
+		hdf['late'] = milestone.is_late
+	if milestone.completed:
+		hdf['completed'] = milestone.completed
+		hdf['completed_date'] = format_datetime(milestone.completed)
+		hdf['completed_delta'] = pretty_timedelta(milestone.completed)
+	return hdf
 
 def _get_groups(env, db, by='component'):
-    for field in TicketSystem(env).get_ticket_fields():
-        if field['name'] == by:
-            if field.has_key('options'):
-                return field['options']
-            else:
-                cursor = db.cursor()
-                cursor.execute("SELECT DISTINCT %s FROM ticket ORDER BY %s"
-                               % (by, by))
-                return [row[0] for row in cursor]
-    return []
+	for field in TicketSystem(env).get_ticket_fields():
+		if field['name'] == by:
+			if field.has_key('options'):
+				return field['options']
+			else:
+				cursor = db.cursor()
+				cursor.execute("SELECT DISTINCT %s FROM ticket ORDER BY %s"
+							   % (by, by))
+				return [row[0] for row in cursor]
+	return []
 
 
 class RoadmapModule(Component):
 
-    implements(INavigationContributor, IPermissionRequestor, IRequestHandler)
+	implements(INavigationContributor, IPermissionRequestor, IRequestHandler)
 
-    # INavigationContributor methods
+	# INavigationContributor methods
 
-    def get_active_navigation_item(self, req):
-        return 'roadmap'
+	def get_active_navigation_item(self, req):
+		return 'roadmap'
 
-    def get_navigation_items(self, req):
-        if not req.perm.has_permission('ROADMAP_VIEW'):
-            return
-        yield 'mainnav', 'roadmap', '<a href="%s" accesskey="3">Roadmap</a>' \
-                                    % self.env.href.roadmap()
+	def get_navigation_items(self, req):
+		if not req.perm.has_permission('ROADMAP_VIEW'):
+			return
+		yield 'mainnav', 'roadmap', '<a href="%s" accesskey="3">Roadmap</a>' \
+									% self.env.href.roadmap()
 
-    # IPermissionRequestor methods
+	# IPermissionRequestor methods
 
-    def get_permission_actions(self):
-        return ['ROADMAP_VIEW']
+	def get_permission_actions(self):
+		return ['ROADMAP_VIEW']
 
-    # IRequestHandler methods
+	# IRequestHandler methods
 
-    def match_request(self, req):
-        return re.match(r'/roadmap/?', req.path_info) is not None
+	def match_request(self, req):
+		return re.match(r'/roadmap/?', req.path_info) is not None
 
-    def process_request(self, req):
-        req.perm.assert_permission('ROADMAP_VIEW')
-        req.hdf['title'] = 'Roadmap'
+	def process_request(self, req):
+		req.perm.assert_permission('ROADMAP_VIEW')
+		req.hdf['title'] = 'Roadmap'
 
-        showall = req.args.get('show') == 'all'
-        req.hdf['roadmap.showall'] = showall
+		showall = req.args.get('show') == 'all'
+		req.hdf['roadmap.showall'] = showall
 
-        db = self.env.get_db_cnx()
-        milestones = []
-        for idx, milestone in enum(Milestone.select(self.env, showall)):
-            hdf = milestone_to_hdf(self.env, db, req, milestone)
-            milestones.append(hdf)
-        req.hdf['roadmap.milestones'] = milestones
+		db = self.env.get_db_cnx()
+		milestones = []
+		for idx, milestone in enum(Milestone.select(self.env, showall)):
+			hdf = milestone_to_hdf(self.env, db, req, milestone)
+			milestones.append(hdf)
+		req.hdf['roadmap.milestones'] = milestones
 
-        for idx,milestone in enum(milestones):
-            prefix = 'roadmap.milestones.%d.' % idx
-            tickets = get_tickets_for_milestone(self.env, db, milestone['name'],
-                                                'owner')
-            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] = escape(v)
-            milestone['tickets'] = tickets # for the iCalendar view
+		for idx,milestone in enum(milestones):
+			prefix = 'roadmap.milestones.%d.' % idx
+			tickets = get_tickets_for_milestone(self.env, db, milestone['name'],
+												[ '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] = escape(v)
+			milestone['tickets'] = tickets # for the iCalendar view
 
-        if req.args.get('format') == 'ics':
-            self.render_ics(req, db, milestones)
-            return
+		if req.args.get('format') == 'ics':
+			self.render_ics(req, db, milestones)
+			return
 
-        add_stylesheet(req, 'common/css/roadmap.css')
+		add_stylesheet(req, 'common/css/roadmap.css')
 
-        # FIXME should use the 'webcal:' scheme, probably
-        username = None
-        if req.authname and req.authname != 'anonymous':
-            username = req.authname
-        icshref = self.env.href.roadmap(show=req.args.get('show'),
-                                        user=username, format='ics')
-        add_link(req, 'alternate', icshref, 'iCalendar', 'text/calendar', 'ics')
+		# FIXME should use the 'webcal:' scheme, probably
+		username = None
+		if req.authname and req.authname != 'anonymous':
+			username = req.authname
+		icshref = self.env.href.roadmap(show=req.args.get('show'),
+										user=username, format='ics')
+		add_link(req, 'alternate', icshref, 'iCalendar', 'text/calendar', 'ics')
 
-        return 'roadmap.cs', None
+		return 'roadmap.cs', None
 
-    # Internal methods
+	# Internal methods
 
-    def render_ics(self, req, db, milestones):
-        req.send_response(200)
-        req.send_header('Content-Type', 'text/calendar;charset=utf-8')
-        req.end_headers()
+	def render_ics(self, req, db, milestones):
+		req.send_response(200)
+		req.send_header('Content-Type', 'text/calendar;charset=utf-8')
+		req.end_headers()
 
-        from trac.ticket import Priority
-        priorities = {}
-        for priority in Priority.select(self.env):
-            priorities[priority.name] = float(priority.value)
-        def get_priority(ticket):
-            value = priorities.get(ticket['priority'])
-            if value:
-                return int(value * 9 / len(priorities))
+		from trac.ticket import Priority
+		priorities = {}
+		for priority in Priority.select(self.env):
+			priorities[priority.name] = float(priority.value)
+		def get_priority(ticket):
+			value = priorities.get(ticket['priority'])
+			if value:
+				return int(value * 9 / len(priorities))
 
-        def get_status(ticket):
-            status = ticket['status']
-            if status == 'new' or status == 'reopened' and not ticket['owner']:
-                return 'NEEDS-ACTION'
-            elif status == 'assigned' or status == 'reopened':
-                return 'IN-PROCESS'
-            elif status == 'closed':
-                if ticket['resolution'] == 'fixed': return 'COMPLETED'
-                else: return 'CANCELLED'
-            else: return ''
+		def get_status(ticket):
+			status = ticket['status']
+			if status == 'new' or status == 'reopened' and not ticket['owner']:
+				return 'NEEDS-ACTION'
+			elif status == 'assigned' or status == 'reopened':
+				return 'IN-PROCESS'
+			elif status == 'closed':
+				if ticket['resolution'] == 'fixed': return 'COMPLETED'
+				else: return 'CANCELLED'
+			else: return ''
 
-        def write_prop(name, value, params={}):
-            text = ';'.join([name] + [k + '=' + v for k, v in params.items()]) \
-                 + ':' + '\\n'.join(re.split(r'[\r\n]+', value))
-            firstline = 1
-            while text:
-                if not firstline: text = ' ' + text
-                else: firstline = 0
-                req.write(text[:75] + CRLF)
-                text = text[75:]
+		def write_prop(name, value, params={}):
+			text = ';'.join([name] + [k + '=' + v for k, v in params.items()]) \
+				 + ':' + '\\n'.join(re.split(r'[\r\n]+', value))
+			firstline = 1
+			while text:
+				if not firstline: text = ' ' + text
+				else: firstline = 0
+				req.write(text[:75] + CRLF)
+				text = text[75:]
 
-        def write_date(name, value, params={}):
-            params['VALUE'] = 'DATE'
-            write_prop(name, strftime('%Y%m%d', value), params)
+		def write_date(name, value, params={}):
+			params['VALUE'] = 'DATE'
+			write_prop(name, strftime('%Y%m%d', value), params)
 
-        def write_utctime(name, value, params={}):
-            write_prop(name, strftime('%Y%m%dT%H%M%SZ', value), params)
+		def write_utctime(name, value, params={}):
+			write_prop(name, strftime('%Y%m%dT%H%M%SZ', value), params)
 
-        host = req.base_url[req.base_url.find('://') + 3:]
-        user = req.args.get('user', 'anonymous')
+		host = req.base_url[req.base_url.find('://') + 3:]
+		user = req.args.get('user', 'anonymous')
 
-        write_prop('BEGIN', 'VCALENDAR')
-        write_prop('VERSION', '2.0')
-        write_prop('PRODID', '-//Edgewall Software//NONSGML Trac %s//EN'
-                   % __version__)
-        write_prop('METHOD', 'PUBLISH')
-        write_prop('X-WR-CALNAME',
-                   self.config.get('project', 'name') + ' - Roadmap')
-        for milestone in milestones:
-            uid = '<%s/milestone/%s@%s>' % (req.cgi_location,
-                                            milestone['name'], host)
-            if milestone.has_key('due'):
-                write_prop('BEGIN', 'VEVENT')
-                write_prop('UID', uid)
-                write_date('DTSTAMP', localtime(milestone['due']))
-                write_date('DTSTART', localtime(milestone['due']))
-                write_prop('SUMMARY', 'Milestone %s' % milestone['name'])
-                write_prop('URL', req.base_url + '/milestone/' +
-                           milestone['name'])
-                if milestone.has_key('description_source'):
-                    write_prop('DESCRIPTION', milestone['description_source'])
-                write_prop('END', 'VEVENT')
-            for tkt_id in [ticket['id'] for ticket in milestone['tickets']
-                           if ticket['owner'] == user]:
-                ticket = Ticket(self.env, tkt_id)
-                write_prop('BEGIN', 'VTODO')
-                if milestone.has_key('date'):
-                    write_prop('RELATED-TO', uid)
-                    write_date('DUE', localtime(milestone['due']))
-                write_prop('SUMMARY', 'Ticket #%i: %s' % (ticket.id,
-                                                          ticket['summary']))
-                write_prop('URL', self.env.abs_href.ticket(ticket.id))
-                write_prop('DESCRIPTION', ticket['description'])
-                priority = get_priority(ticket)
-                if priority:
-                    write_prop('PRIORITY', str(priority))
-                write_prop('STATUS', get_status(ticket))
-                if ticket['status'] == 'closed':
-                    cursor = db.cursor()
-                    cursor.execute("SELECT time FROM ticket_change "
-                                   "WHERE ticket=%s AND field='status' "
-                                   "ORDER BY time desc LIMIT 1",
-                                   (ticket.id,))
-                    row = cursor.fetchone()
-                    if row:
-                        write_utctime('COMPLETED', localtime(row[0]))
-                write_prop('END', 'VTODO')
-        write_prop('END', 'VCALENDAR')
+		write_prop('BEGIN', 'VCALENDAR')
+		write_prop('VERSION', '2.0')
+		write_prop('PRODID', '-//Edgewall Software//NONSGML Trac %s//EN'
+				   % __version__)
+		write_prop('METHOD', 'PUBLISH')
+		write_prop('X-WR-CALNAME',
+				   self.config.get('project', 'name') + ' - Roadmap')
+		for milestone in milestones:
+			uid = '<%s/milestone/%s@%s>' % (req.cgi_location,
+											milestone['name'], host)
+			if milestone.has_key('due'):
+				write_prop('BEGIN', 'VEVENT')
+				write_prop('UID', uid)
+				write_date('DTSTAMP', localtime(milestone['due']))
+				write_date('DTSTART', localtime(milestone['due']))
+				write_prop('SUMMARY', 'Milestone %s' % milestone['name'])
+				write_prop('URL', req.base_url + '/milestone/' +
+						   milestone['name'])
+				if milestone.has_key('description_source'):
+					write_prop('DESCRIPTION', milestone['description_source'])
+				write_prop('END', 'VEVENT')
+			for tkt_id in [ticket['id'] for ticket in milestone['tickets']
+						   if ticket['owner'] == user]:
+				ticket = Ticket(self.env, tkt_id)
+				write_prop('BEGIN', 'VTODO')
+				if milestone.has_key('date'):
+					write_prop('RELATED-TO', uid)
+					write_date('DUE', localtime(milestone['due']))
+				write_prop('SUMMARY', 'Ticket #%i: %s' % (ticket.id,
+														  ticket['summary']))
+				write_prop('URL', self.env.abs_href.ticket(ticket.id))
+				write_prop('DESCRIPTION', ticket['description'])
+				priority = get_priority(ticket)
+				if priority:
+					write_prop('PRIORITY', str(priority))
+				write_prop('STATUS', get_status(ticket))
+				if ticket['status'] == 'closed':
+					cursor = db.cursor()
+					cursor.execute("SELECT time FROM ticket_change "
+								   "WHERE ticket=%s AND field='status' "
+								   "ORDER BY time desc LIMIT 1",
+								   (ticket.id,))
+					row = cursor.fetchone()
+					if row:
+						write_utctime('COMPLETED', localtime(row[0]))
+				write_prop('END', 'VTODO')
+		write_prop('END', 'VCALENDAR')
 
 
 class MilestoneModule(Component):
 
-    implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
-               ITimelineEventProvider, IWikiSyntaxProvider)
+	implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
+			   ITimelineEventProvider, IWikiSyntaxProvider)
 
-    # INavigationContributor methods
+	# INavigationContributor methods
 
-    def get_active_navigation_item(self, req):
-        return 'roadmap'
+	def get_active_navigation_item(self, req):
+		return 'roadmap'
 
-    def get_navigation_items(self, req):
-        return []
+	def get_navigation_items(self, req):
+		return []
 
-    # IPermissionRequestor methods
+	# IPermissionRequestor methods
 
-    def get_permission_actions(self):
-        actions = ['MILESTONE_CREATE', 'MILESTONE_DELETE', 'MILESTONE_MODIFY',
-                   'MILESTONE_VIEW']
-        return actions + [('MILESTONE_ADMIN', actions),
-                          ('ROADMAP_ADMIN', actions)]
+	def get_permission_actions(self):
+		actions = ['MILESTONE_CREATE', 'MILESTONE_DELETE', 'MILESTONE_MODIFY',
+				   'MILESTONE_VIEW']
+		return actions + [('MILESTONE_ADMIN', actions),
+						  ('ROADMAP_ADMIN', actions)]
 
-    # ITimelineEventProvider methods
+	# ITimelineEventProvider methods
 
-    def get_timeline_filters(self, req):
-        if req.perm.has_permission('MILESTONE_VIEW'):
-            yield ('milestone', 'Milestones')
+	def get_timeline_filters(self, req):
+		if req.perm.has_permission('MILESTONE_VIEW'):
+			yield ('milestone', 'Milestones')
 
-    def get_timeline_events(self, req, start, stop, filters):
-        if 'milestone' in filters:
-            format = req.args.get('format')
-            db = self.env.get_db_cnx()
-            cursor = db.cursor()
-            cursor.execute("SELECT completed,name,description FROM milestone "
-                           "WHERE completed>=%s AND completed<=%s",
-                           (start, stop,))
-            for completed,name,description in cursor:
-                title = 'Milestone <em>%s</em> completed' % escape(name)
-                if format == 'rss':
-                    href = self.env.abs_href.milestone(name)
-                    message = wiki_to_html(description or '--', self.env, db,
-                                           absurls=True)
-                else:
-                    href = self.env.href.milestone(name)
-                    message = wiki_to_oneliner(description, self.env, db,
-                                               shorten=True)
-                yield 'milestone', href, title, completed, None, message
+	def get_timeline_events(self, req, start, stop, filters):
+		if 'milestone' in filters:
+			format = req.args.get('format')
+			db = self.env.get_db_cnx()
+			cursor = db.cursor()
+			cursor.execute("SELECT completed,name,description FROM milestone "
+						   "WHERE completed>=%s AND completed<=%s",
+						   (start, stop,))
+			for completed,name,description in cursor:
+				title = 'Milestone <em>%s</em> completed' % escape(name)
+				if format == 'rss':
+					href = self.env.abs_href.milestone(name)
+					message = wiki_to_html(description or '--', self.env, db,
+										   absurls=True)
+				else:
+					href = self.env.href.milestone(name)
+					message = wiki_to_oneliner(description, self.env, db,
+											   shorten=True)
+				yield 'milestone', href, title, completed, None, message
 
-    # IRequestHandler methods
+	# IRequestHandler methods
 
-    def match_request(self, req):
-        import re, urllib
-        match = re.match(r'/milestone(?:/(.+))?', req.path_info)
-        if match:
-            if match.group(1):
-                req.args['id'] = urllib.unquote(match.group(1))
-            return True
+	def match_request(self, req):
+		import re, urllib
+		match = re.match(r'/milestone(?:/(.+))?', req.path_info)
+		if match:
+			if match.group(1):
+				req.args['id'] = urllib.unquote(match.group(1))
+			return True
 
-    def process_request(self, req):
-        req.perm.assert_permission('MILESTONE_VIEW')
+	def process_request(self, req):
+		req.perm.assert_permission('MILESTONE_VIEW')
 
-        add_link(req, 'up', self.env.href.roadmap(), 'Roadmap')
+		add_link(req, 'up', self.env.href.roadmap(), 'Roadmap')
 
-        db = self.env.get_db_cnx()
-        milestone = Milestone(self.env, req.args.get('id'), db)
-        action = req.args.get('action', 'view')
+		db = self.env.get_db_cnx()
+		milestone = Milestone(self.env, req.args.get('id'), db)
+		action = req.args.get('action', 'view')
 
-        if req.method == 'POST':
-            if req.args.has_key('cancel'):
-                if milestone.exists:
-                    safe_name = milestone.name.replace('/', '%2F')
-                    req.redirect(self.env.href.milestone(safe_name))
-                else:
-                    req.redirect(self.env.href.roadmap())
-            elif action == 'edit':
-                self._do_save(req, db, milestone)
-            elif action == 'delete':
-                self._do_delete(req, db, milestone)
-        elif action in ('new', 'edit'):
-            self._render_editor(req, db, milestone)
-        elif action == 'delete':
-            self._render_confirm(req, db, milestone)
-        else:
-            self._render_view(req, db, milestone)
+		if req.method == 'POST':
+			if req.args.has_key('cancel'):
+				if milestone.exists:
+					safe_name = milestone.name.replace('/', '%2F')
+					req.redirect(self.env.href.milestone(safe_name))
+				else:
+					req.redirect(self.env.href.roadmap())
+			elif action == 'edit':
+				self._do_save(req, db, milestone)
+			elif action == 'delete':
+				self._do_delete(req, db, milestone)
+		elif action in ('new', 'edit'):
+			self._render_editor(req, db, milestone)
+		elif action == 'delete':
+			self._render_confirm(req, db, milestone)
+		else:
+			self._render_view(req, db, milestone)
 
-        add_stylesheet(req, 'common/css/roadmap.css')
-        return 'milestone.cs', None
+		add_stylesheet(req, 'common/css/roadmap.css')
+		return 'milestone.cs', None
 
-    # Internal methods
+	# Internal methods
 
-    def _do_delete(self, req, db, milestone):
-        req.perm.assert_permission('MILESTONE_DELETE')
+	def _do_delete(self, req, db, milestone):
+		req.perm.assert_permission('MILESTONE_DELETE')
 
-        retarget_to = None
-        if req.args.has_key('retarget'):
-            retarget_to = req.args.get('target')
-        milestone.delete(retarget_to, req.authname)
-        db.commit()
-        req.redirect(self.env.href.roadmap())
+		retarget_to = None
+		if req.args.has_key('retarget'):
+			retarget_to = req.args.get('target')
+		milestone.delete(retarget_to, req.authname)
+		db.commit()
+		req.redirect(self.env.href.roadmap())
 
-    def _do_save(self, req, db, milestone):
-        if milestone.exists:
-            req.perm.assert_permission('MILESTONE_MODIFY')
-        else:
-            req.perm.assert_permission('MILESTONE_CREATE')
+	def _do_save(self, req, db, milestone):
+		if milestone.exists:
+			req.perm.assert_permission('MILESTONE_MODIFY')
+		else:
+			req.perm.assert_permission('MILESTONE_CREATE')
 
-        if not req.args.has_key('name'):
-            raise TracError('You must provide a name for the milestone.',
-                            'Required Field Missing')
-        milestone.name = req.args.get('name')
+		if not req.args.has_key('name'):
+			raise TracError('You must provide a name for the milestone.',
+							'Required Field Missing')
+		milestone.name = req.args.get('name')
 
-        due = req.args.get('duedate', '')
-        try:
-            milestone.due = due and parse_date(due) or 0
-        except ValueError, e:
-            raise TracError(e, 'Invalid Date Format')
-        if req.args.has_key('completed'):
-            completed = req.args.get('completeddate', '')
-            try:
-                milestone.completed = completed and parse_date(completed) or 0
-            except ValueError, e:
-                raise TracError(e, 'Invalid Date Format')
-            if milestone.completed > time():
-                raise TracError('Completion date may not be in the future',
-                                'Invalid Completion Date')
-        else:
-            milestone.completed = 0
+		due = req.args.get('duedate', '')
+		try:
+			milestone.due = due and parse_date(due) or 0
+		except ValueError, e:
+			raise TracError(e, 'Invalid Date Format')
+		if req.args.has_key('completed'):
+			completed = req.args.get('completeddate', '')
+			try:
+				milestone.completed = completed and parse_date(completed) or 0
+			except ValueError, e:
+				raise TracError(e, 'Invalid Date Format')
+			if milestone.completed > time():
+				raise TracError('Completion date may not be in the future',
+								'Invalid Completion Date')
+		else:
+			milestone.completed = 0
 
-        milestone.description = req.args.get('description', '')
+		milestone.description = req.args.get('description', '')
 
-        if milestone.exists:
-            milestone.update()
-        else:
-            milestone.insert()
-        db.commit()
+		if milestone.exists:
+			milestone.update()
+		else:
+			milestone.insert()
+		db.commit()
 
-        safe_name = milestone.name.replace('/', '%2F')
-        req.redirect(self.env.href.milestone(safe_name))
+		safe_name = milestone.name.replace('/', '%2F')
+		req.redirect(self.env.href.milestone(safe_name))
 
-    def _render_confirm(self, req, db, milestone):
-        req.perm.assert_permission('MILESTONE_DELETE')
+	def _render_confirm(self, req, db, milestone):
+		req.perm.assert_permission('MILESTONE_DELETE')
 
-        req.hdf['title'] = 'Milestone %s' % milestone.name
-        req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
-        req.hdf['milestone.mode'] = 'delete'
+		req.hdf['title'] = 'Milestone %s' % milestone.name
+		req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
+		req.hdf['milestone.mode'] = 'delete'
 
-        for idx,other in enum(Milestone.select(self.env, False, db)):
-            if other.name == milestone.name:
-                continue
-            req.hdf['milestones.%d' % idx] = other.name
+		for idx,other in enum(Milestone.select(self.env, False, db)):
+			if other.name == milestone.name:
+				continue
+			req.hdf['milestones.%d' % idx] = other.name
 
-    def _render_editor(self, req, db, milestone):
-        if milestone.exists:
-            req.perm.assert_permission('MILESTONE_MODIFY')
-            req.hdf['title'] = 'Milestone %s' % milestone.name
-            req.hdf['milestone.mode'] = 'edit'
-        else:
-            req.perm.assert_permission('MILESTONE_CREATE')
-            req.hdf['title'] = 'New Milestone'
-            req.hdf['milestone.mode'] = 'new'
+	def _render_editor(self, req, db, milestone):
+		if milestone.exists:
+			req.perm.assert_permission('MILESTONE_MODIFY')
+			req.hdf['title'] = 'Milestone %s' % milestone.name
+			req.hdf['milestone.mode'] = 'edit'
+		else:
+			req.perm.assert_permission('MILESTONE_CREATE')
+			req.hdf['title'] = 'New Milestone'
+			req.hdf['milestone.mode'] = 'new'
 
-        from trac.util import get_date_format_hint, get_datetime_format_hint
-        req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
-        req.hdf['milestone.date_hint'] = get_date_format_hint()
-        req.hdf['milestone.datetime_hint'] = get_datetime_format_hint()
-        req.hdf['milestone.datetime_now'] = format_datetime()
+		from trac.util import get_date_format_hint, get_datetime_format_hint
+		req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
+		req.hdf['milestone.date_hint'] = get_date_format_hint()
+		req.hdf['milestone.datetime_hint'] = get_datetime_format_hint()
+		req.hdf['milestone.datetime_now'] = format_datetime()
 
-    def _render_view(self, req, db, milestone):
-        req.hdf['title'] = 'Milestone %s' % milestone.name
-        req.hdf['milestone.mode'] = 'view'
+	def _render_view(self, req, db, milestone):
+		req.hdf['title'] = 'Milestone %s' % milestone.name
+		req.hdf['milestone.mode'] = 'view'
 
-        req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
+		req.hdf['milestone'] = milestone_to_hdf(self.env, db, req, milestone)
 
-        available_groups = []
-        component_group_available = False
-        for field in TicketSystem(self.env).get_ticket_fields():
-            if field['type'] == 'select' and field['name'] != 'milestone' \
-                    or field['name'] == 'owner':
-                available_groups.append({'name': field['name'],
-                                         'label': field['label']})
-                if field['name'] == 'component':
-                    component_group_available = True
-        req.hdf['milestone.stats.available_groups'] = available_groups
+		available_groups = []
+		component_group_available = False
+		for field in TicketSystem(self.env).get_ticket_fields():
+			if field['type'] == 'select' and field['name'] != 'milestone' \
+					or field['name'] == 'owner':
+				available_groups.append({'name': field['name'],
+										 'label': field['label']})
+				if field['name'] == 'component':
+					component_group_available = True
+		req.hdf['milestone.stats.available_groups'] = available_groups
 
-        if component_group_available:
-            by = req.args.get('by', 'component')
-        else:
-            by = req.args.get('by', available_groups[0]['name'])
-        req.hdf['milestone.stats.grouped_by'] = by
+		if component_group_available:
+			by = req.args.get('by', 'component')
+		else:
+			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)
-        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] = escape(value)
+		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] = escape(value)
 
-        groups = _get_groups(self.env, db, by)
-        group_no = 0
-        max_percent_total = 0
-        for group in groups:
-            group_tickets = [t for t in tickets if t[by] == group]
-            if not group_tickets:
-                continue
-            prefix = 'milestone.stats.groups.%s' % group_no
-            req.hdf['%s.name' % prefix] = group
-            percent_total = 0
-            if len(tickets) > 0:
-                percent_total = float(len(group_tickets)) / float(len(tickets))
-                if percent_total > max_percent_total:
-                    max_percent_total = percent_total
-            req.hdf['%s.percent_total' % prefix] = percent_total * 100
-            stats = calc_ticket_stats(group_tickets)
-            req.hdf[prefix] = stats
-            for key, value in get_query_links(self.env, milestone.name,
-                                              by, group).items():
-                req.hdf['%s.queries.%s' % (prefix, key)] = escape(value)
-            group_no += 1
-        req.hdf['milestone.stats.max_percent_total'] = max_percent_total * 100
+		groups = _get_groups(self.env, db, by)
+		group_no = 0
+		max_percent_total = 0
+		for group in groups:
+			group_tickets = [t for t in tickets if t[by] == group]
+			if not group_tickets:
+				continue
+			prefix = 'milestone.stats.groups.%s' % group_no
+			req.hdf['%s.name' % prefix] = group
+			percent_total = 0
+			if len(tickets) > 0:
+				percent_total = float(len(group_tickets)) / float(len(tickets))
+				if percent_total > max_percent_total:
+					max_percent_total = percent_total
+			req.hdf['%s.percent_total' % prefix] = percent_total * 100
+			stats = calc_ticket_stats(group_tickets)
+			req.hdf[prefix] = stats
+			for key, value in get_query_links(self.env, milestone.name,
+											  by, group).items():
+				req.hdf['%s.queries.%s' % (prefix, key)] = escape(value)
+			group_no += 1
+		req.hdf['milestone.stats.max_percent_total'] = max_percent_total * 100
 
-    # IWikiSyntaxProvider methods
+	# IWikiSyntaxProvider methods
 
-    def get_wiki_syntax(self):
-        return []
+	def get_wiki_syntax(self):
+		return []
 
-    def get_link_resolvers(self):
-        yield ('milestone', self._format_link)
+	def get_link_resolvers(self):
+		yield ('milestone', self._format_link)
 
-    def _format_link(self, formatter, ns, name, label):
-        return '<a class="milestone" href="%s">%s</a>' \
-               % (formatter.href.milestone(name), label)
+	def _format_link(self, formatter, ns, name, label):
+		return '<a class="milestone" href="%s">%s</a>' \
+			   % (formatter.href.milestone(name), label)
Index: templates/roadmap.cs
===================================================================
--- templates/roadmap.cs	(revision 2449)
+++ templates/roadmap.cs	(working copy)
@@ -61,6 +61,24 @@
          var:stats.active_tickets ?></a></dd>
       </dl><?cs
      /if ?><?cs
+     if:#stats.spent_work > #0 ?>
+	  <div style="margin: 0; height: 1px;"></div>
+      <div class="progress">
+	    <div class="closed" style="width: <?cs 
+		  var:#stats.work_percent_complete ?>%"></div>
+	    <div class="open" style="width: <?cs
+		  var:#stats.work_percent_remaining ?>%"></div>
+      </div>
+      <p class="percent"><?cs var:#stats.work_percent_complete ?>%</p>     
+      <dl>
+       <dt>Estimated work:</dt>
+       <dd><?cs var:stats.estimated_work ?> 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/macros.cs
===================================================================
--- templates/macros.cs	(revision 2449)
+++ templates/macros.cs	(working copy)
@@ -131,39 +131,47 @@
  each c=ticket.custom ?>
   <div class="field custom_<?cs var c.name ?>"><?cs
    if c.type == 'text' ?>
-    <label>
-     <?cs alt c.label ?><?cs var c.name ?><?cs /alt ?>:
-     <input type="text" name="custom_<?cs var c.name ?>" value="<?cs var c.value ?>" />
-    </label><?cs
+    <label for="custom_<?cs var c.name ?>">
+     <?cs alt c.label ?><?cs var c.name ?><?cs /alt ?>:</label>
+     <input type="text" name="custom_<?cs var c.name ?>"
+      id="custom_<?cs var c.name ?>" value="<?cs var c.value ?>" />
+    <?cs
    elif c.type == 'textarea' ?>
-    <label>
-     <?cs alt c.label ?><?cs var c.name ?><?cs /alt ?>:<br />
-     <textarea cols="<?cs alt c.width ?>60<?cs /alt ?>" rows="<?cs
+    <label for="custom_<?cs var c.name ?>">
+     <?cs alt c.label ?><?cs var c.name ?><?cs /alt ?>:<br /></label>
+     <textarea id="custom_<?cs var c.name ?>"
+       cols="<?cs alt c.width ?>60<?cs /alt ?>" rows="<?cs
        alt c.height ?>12<?cs /alt ?>" name="custom_<?cs var c.name ?>"><?cs
        var c.value ?></textarea>
-    </label><?cs
+    <?cs
    elif c.type == 'checkbox' ?>
     <input type="hidden" name="checkbox_<?cs var c.name ?>" />
-    <label>
-     <input type="checkbox" name="custom_<?cs var c.name ?>" value="1"<?cs
+    <label for="custom_<?cs var c.name ?>">&nbsp;</label>
+     <input type="checkbox" name="custom_<?cs var c.name ?>" 
+       id="custom_<?cs var c.name ?>" value="1"<?cs
        if c.selected ?> checked="checked"<?cs /if ?> />
      <?cs alt c.label ?><?cs var c.name ?><?cs /alt ?>
-    </label><?cs
+    <?cs
    elif c.type == 'select' ?>
-    <label>
-     <?cs alt c.label ?><?cs var c.name ?><?cs /alt ?>:
-     <select name="custom_<?cs var c.name ?>"><?cs each v = c.option ?>
+    <label for="custom_<?cs var c.name ?>">
+     <?cs alt c.label ?><?cs var c.name ?><?cs /alt ?>:</label>
+     <select name="custom_<?cs var c.name ?>" 
+      id="custom_<?cs var c.name ?>"><?cs each v = c.option ?>
       <option<?cs if v.selected ?> selected="selected"<?cs /if ?>><?cs
         var v ?></option><?cs /each ?>
      </select>
-    </label><?cs
+    <?cs
    elif c.type == 'radio' ?>
-    <fieldset class="radio">
-     <legend><?cs alt c.label ?><?cs var c.name ?><?cs /alt ?>:</legend><?cs
+    <label for="custom_<?cs var c.name ?>"><?cs alt c.label ?><?cs var
+     c.name ?><?cs /alt ?>:</label>
+    <fieldset class="radio" id="custom_<?cs var c.name ?>"><?cs
      each v = c.option ?>
-      <label><input type="radio" name="custom_<?cs var c.name ?>" value="<?cs
+      <label for="custom_<?cs var c.name ?>_<?cs var v ?>">
+       <input type="radio" id="custom_<?cs var c.name ?>_<?cs var v ?>"
+         name="custom_<?cs var c.name ?>" value="<?cs
          var v ?>"<?cs if v.selected ?> checked="checked"<?cs /if ?> /> <?cs
-         var v ?></label><?cs
+         var v ?>
+      </label><?cs
      /each ?>
     </fieldset><?cs
    /if ?>
Index: templates/milestone.cs
===================================================================
--- templates/milestone.cs	(revision 2449)
+++ templates/milestone.cs	(working copy)
@@ -141,6 +141,24 @@
         var:stats.active_tickets ?></a></dd>
      </dl><?cs
     /if ?><?cs
+   if:#stats.spent_work > #0 ?>
+	<div style="margin: 0; height: 1px;"></div>
+	<div class="progress">
+	 <div class="closed" style="width: <?cs 
+		var:#stats.work_percent_complete ?>%"></div>
+	 <div class="open" style="width: <?cs
+		var:#stats.work_percent_remaining ?>%"></div>
+	</div>
+	<p class="percent"><?cs var:#stats.work_percent_complete ?>%</p>     
+	<dl>
+	 <dt>Estimated work:</dt>
+	 <dd><?cs var:stats.estimated_work ?> 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 2449)
+++ contrib/trac-post-commit-hook	(working copy)
@@ -115,7 +115,22 @@
 
 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|h)')
+remainingPattern = re.compile(r'(remaining|rem) ([0-9]*)(m|h)')
 
+def _toHour(value, unit):
+	if (unit.lower() == 'm'):
+		value = float(value) / 60
+	return float(value)
+
+def _parseTime(value):
+	match = re.search(r'([0-9]*)(m|h)', value)
+	if match:
+		value = match.group(1)
+		unit = match.group(2)
+		return _toHour(value, unit)
+	return 0
+
 class CommitHook:
     _supported_cmds = {'close':      '_cmdClose',
                        'closed':     '_cmdClose',
@@ -141,16 +156,46 @@
         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 = _toHour(value, unit)
+
+        self.time_remaining = -1
+        groups = remainingPattern.findall(msg) 
+        for cmd, value, unit in groups:
+			self.time_remaining = _toHour(value, unit)
+
+        ticket = Ticket(self.db, 1)
+        self._saveCustomFields(ticket)
+
         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 _saveCustomFields(self, ticket):
+		if (self.time_spent != -1):
+			if (ticket.has_key('custom_tt_spent')):
+				ticket['custom_tt_spent'] = str(_parseTime(ticket['custom_tt_spent']) + self.time_spent) + 'h'
+			else:
+				ticket['custom_tt_spent'] = str(self.time_spent) + 'h'
+
+			if (ticket.has_key('custom_tt_remaining')):
+				ticket['custom_tt_remaining'] = str(_parseTime(ticket['custom_tt_remaining']) - self.time_spent) + 'h'
+			else:
+				if (ticket.has_key('custom_tt_planned')):
+					ticket['custom_tt_remaining'] = str(_parseTime(ticket['custom_tt_planned']) - self.time_spent) + 'h'
+
+		if (self.time_remaining != -1):
+			ticket['custom_tt_remaining'] = str(self.time_remaining) + 'h'
+
     def _cmdClose(self, tickets):
         for tkt_id in tickets:
             try:
                 ticket = Ticket(self.env, tkt_id)
+                self._saveCustomFields(ticket)
                 ticket['status'] = 'closed'
                 ticket['resolution'] = 'fixed'
                 ticket.save_changes(self.author, self.msg, self.now)
@@ -164,14 +209,15 @@
         for tkt_id in tickets: 
             try:
                 ticket = Ticket(self.env, tkt_id)
+                self._saveCustomFields(ticket)
                 ticket.save_changes(self.author, self.msg, self.now)
                 tn = TicketNotifyEmail(self.env)
                 tn.notify(ticket, newticket=0, modtime=self.now)
             except Exception, e:
                 print>>sys.stderr, 'Unexpected error while processing ticket ' \
                                    'ID %s: %s' % (tkt_id, e)
+		
 
-
 if __name__ == "__main__":
     if len(sys.argv) < 5:
         print "For usage: %s --help" % (sys.argv[0])

