Index: wiki-default/TracTickets
===================================================================
--- wiki-default/TracTickets	(revision 1283)
+++ wiki-default/TracTickets	(working copy)
@@ -29,7 +29,12 @@
  * '''Status''' - What is the current status?
  * '''Summary''' - A brief description summarizing the problem or issue.
  * '''Description''' - The body of the ticket. A good description should be '''specific, descriptive and to the point'''.
+ * '''Planned work''' - The planned work to resolve the ticket in man hours. This field is optional.
+ * '''Actual work''' - The work amount already spent on the resolution of the ticket in man hours.
+ * '''Remaining work''' - The remaining work amount planned for the ticket. This value is computed automatically from the '''Planned work''' and '''Actual work''' fields.
 
+
+
 == Changing and Commenting Tickets ==
 
 Once a ticket has been entered into Trac, you can at any time change the
@@ -38,6 +43,9 @@
 
 When viewing a ticket, this log of changes will appear below the main ticket area.
 
+The '''Planned work''' and '''Actual work''' fields along with some custom reports can be used for simple project planning and project controlling.
+The '''Actual work''' field cannot be updated directly. In the '''Properties''' block a '''Spent work''' field is shown. The value entered into this field (in man hours) will be added to the value of the '''Actual work''' field. In the ticket changelog the original value is saved. This way the work spent on every ticket change can be tracked. This feature can be used for simple work hour reporting and project metrics.
+
 ''In the Trac project, we use ticket comments to discuss issues and
 tasks. This makes understanding the motivation behind a design- or implementation choice easier, when returning to it later.''
 
@@ -72,4 +80,4 @@
 '''Example:''' ''/trac/newticket?summary=Compile%20Error&version=1.0&component=gui''
 
 
-See also:  TracGuide, TracWiki, TracTicketsCustomFields, TracNotification
\ No newline at end of file
+See also:  TracGuide, TracWiki, TracTicketsCustomFields, TracNotification
Index: wiki-default/TracRoadmap
===================================================================
--- wiki-default/TracRoadmap	(revision 1283)
+++ wiki-default/TracRoadmap	(working copy)
@@ -5,8 +5,10 @@
 
 == The Roadmap View ==
 
-Basically, the roadmap is just a list of future milestones. You can add a description to milestones (using WikiFormatting) describing main objectives, for example. In addition, tickets targeted for a milestone are aggregated, and the ratio between active and resolved tickets is displayed as a milestone progress bar.
+Basically, the roadmap is just a list of future milestones. You can add a description to milestones (using WikiFormatting) describing main objectives, for example. In addition, tickets targeted for a milestone are aggregated, and the ratio between active and resolved tickets is displayed as a milestone progress bar. 
 
+A second progress bar is shown if the tickets targeted for the milestone have their '''Planned work''' and '''Actual work''' properties filled out. This bar shows the total planned work and the already spent work for the tickets. Using this feature you get a more sophisticated overview of your projects.
+
 == The Milestone View ==
 
 It is possible to drill down into this simple statistic by viewing the individual milestone pages. By default, the active/resolved ratio will be grouped and displayed by component. You can also regroup the status by other criteria, such as ticket owner or severity. Ticket numbers are linked to [wiki:TracQuery custom queries] listing corresponding tickets.
@@ -26,4 +28,4 @@
 '''Note:''' For tickets to be included in the calendar (as TO-DO items), you need to be authenticated when copying the link. You will only see tickets assigned to yourself, and associated with a milestone.
 
 ----
-See also: TracTickets, TracReports, TracQuery, TracGuide
\ No newline at end of file
+See also: TracTickets, TracReports, TracQuery, TracGuide
Index: trac/db_default.py
===================================================================
--- trac/db_default.py	(revision 1283)
+++ trac/db_default.py	(working copy)
@@ -94,7 +94,9 @@
         resolution      text,
         summary         text,           -- one-line summary
         description     text,           -- problem description (long)
-        keywords        text
+        keywords        text,
+        planned_work    text,           -- planned work for the ticket in man hours
+        actual_work     text            -- actual work spent on the ticket
 );
 CREATE TABLE ticket_change (
         ticket          integer,
@@ -338,7 +340,27 @@
   WHERE status IN ('new', 'assigned', 'reopened') 
 AND p.name = t.priority AND p.type = 'priority'
   ORDER BY (owner = '$USER') DESC, p.value, milestone, severity, time
-"""))
+"""),
+('Active Tickets over budget',
+"""
+ * List all active tickets that are over budget. (In non-manager talk: \'\'\'Actual work\'\'\' is more than \'\'\'Planned work\'\'\')
+ * Color each row based on priority.
+ * If a ticket has been accepted, a '*' is appended after the owner's name
+""",
+"""
+SELECT p.value AS __color__,
+   id AS ticket, summary, component, version, milestone, severity, 
+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status IN ('new', 'assigned', 'reopened') 
+        AND (actual_work + 0) > (planned_work + 0)
+        AND p.name = t.priority AND p.type = 'priority'
+  ORDER BY p.value, milestone, severity, time
+""")
+)
 
 
 ##
Index: trac/Milestone.py
===================================================================
--- trac/Milestone.py	(revision 1283)
+++ trac/Milestone.py	(working copy)
@@ -31,7 +31,8 @@
 def get_tickets_for_milestone(env, db, milestone, field='component'):
     custom = field not in Ticket.std_fields
     cursor = db.cursor()
-    sql = 'SELECT ticket.id AS id, ticket.status AS status, '
+    sql = 'SELECT ticket.id AS id, ticket.status AS status, ' \
+          'ticket.planned_work AS planned_work, ticket.actual_work AS actual_work, '
     if custom:
         sql += 'ticket_custom.value AS %s ' \
                'FROM ticket LEFT OUTER JOIN ticket_custom ON id = ticket ' \
@@ -50,6 +51,8 @@
         ticket = {
             'id': int(row['id']),
             'status': row['status'],
+            'planned_work': row['planned_work'],
+            'actual_work': row['actual_work'],
             field: row[field]
         }
         tickets.append(ticket)
@@ -81,19 +84,34 @@
 
 def calc_ticket_stats(tickets):
     total_cnt = len(tickets)
-    active = [ticket for ticket in tickets if ticket['status'] != 'closed']
-    active_cnt = len(active)
+    planned_work = 0.0
+    actual_work = 0.0
+    active_cnt = 0    
+    for ticket in tickets:
+        planned_work += float(ticket['planned_work'])
+        actual_work += float(ticket['actual_work'])
+        if ticket['status'] != 'closed':
+            active_cnt += 1
+
     closed_cnt = total_cnt - active_cnt
 
     percent_complete = 0
     if total_cnt > 0:
         percent_complete = float(closed_cnt) / float(total_cnt) * 100
 
+
+    work_percent_complete = 0
+    if planned_work > 0:
+        work_percent_complete = float(actual_work) / float(planned_work) * 100
+
     return {
         'total_tickets': total_cnt,
         'active_tickets': active_cnt,
         'closed_tickets': closed_cnt,
-        'percent_complete': percent_complete
+        'percent_complete': percent_complete,
+        'planned_work': planned_work,
+        'actual_work' : actual_work,
+        'work_percent_complete': work_percent_complete        
     }
 
 
@@ -340,8 +358,8 @@
                 percent_total = float(len(group_tickets)) / float(len(tickets))
             self.req.hdf.setValue('%s.percent_total' % prefix,
                                   str(percent_total * 100))
-            stats = calc_ticket_stats(group_tickets)
-            add_to_hdf(stats, self.req.hdf, prefix)
+            group_stats = calc_ticket_stats(group_tickets)
+            add_to_hdf(group_stats, self.req.hdf, prefix)
             queries = get_query_links(self.env, milestone['name'], by, group)
             add_to_hdf(queries, self.req.hdf, '%s.queries' % prefix)
             group_no += 1
Index: trac/Ticket.py
===================================================================
--- trac/Ticket.py	(revision 1283)
+++ trac/Ticket.py	(working copy)
@@ -37,7 +37,7 @@
 class Ticket(UserDict):
     std_fields = ['time', 'component', 'severity', 'priority', 'milestone',
                   'reporter', 'owner', 'cc', 'url', 'version', 'status', 'resolution',
-                  'keywords', 'summary', 'description']
+                  'keywords', 'summary', 'description', 'planned_work', 'actual_work']
 
     def __init__(self, *args):
         UserDict.__init__(self)
@@ -78,6 +78,12 @@
         if rows:
             for r in rows:
                 self['custom_' + r[0]] = r[1]
+        
+        # Compute the remaining work
+        if self['planned_work']:
+            remaining_work = float(self['planned_work']) - float(self['actual_work'])
+            self['remaining_work'] = remaining_work
+        
         self._forget_changes()
 
     def populate(self, dict):
@@ -102,6 +108,9 @@
         now = int(time.time())
         self['time'] = now
         self['changetime'] = now
+        self['actual_work'] = 0
+        if not self['planned_work']:
+            self['planned_work'] = 0            
 
         std_fields = filter(lambda n: n[:7] != 'custom_', self.keys())
         custom_fields = filter(lambda n: n[:7] == 'custom_', self.keys())
@@ -119,7 +128,7 @@
         self._forget_changes()
         return id
 
-    def save_changes(self, db, author, comment, when = 0):
+    def save_changes(self, db, author, comment, spent_work, when = 0):
         """Store ticket changes in the database.
         The ticket must already exist in the database."""
         assert self.has_key('id')
@@ -128,7 +137,7 @@
             when = int(time.time())
         id = self['id']
 
-        if not self._old and not comment: return # Not modified
+        if not self._old and not comment and not spent_work: return # Not modified
 
         # If the component is changed on a 'new' ticket then owner field
         # is updated accordingly. (#623).
@@ -155,16 +164,24 @@
                 fname = name
                 cursor.execute ('UPDATE ticket SET %s=%s WHERE id=%s',
                                 fname, self[name], id)
-
             cursor.execute ('INSERT INTO ticket_change '
-                            '(ticket, time, author, field, oldvalue, newvalue) '
-                            'VALUES (%s, %s, %s, %s, %s, %s)',
-                            id, when, author, fname, self._old[name], self[name])
+                              '(ticket, time, author, field, oldvalue, newvalue) '
+                              'VALUES (%s, %s, %s, %s, %s, %s)',
+                              id, when, author, fname, self._old[name], self[name])
         if comment:
             cursor.execute ('INSERT INTO ticket_change '
                             '(ticket,time,author,field,oldvalue,newvalue) '
                             "VALUES (%s, %s, %s, 'comment', '', %s)",
                             id, when, author, comment)
+        if spent_work:
+            fname = 'actual_work'
+            actual_work = float(self[fname]) + float(spent_work)
+            cursor.execute ('UPDATE ticket SET %s=%s WHERE id=%s',
+                                fname, actual_work, id)            
+            cursor.execute ('INSERT INTO ticket_change '
+                                '(ticket, time, author, field, newvalue) '
+                                'VALUES (%s, %s, %s, %s, %s)',
+                                id, when, author, 'spent_work', spent_work)
 
         cursor.execute ('UPDATE ticket SET changetime=%s WHERE id=%s', when, id)
         db.commit()
@@ -372,6 +389,7 @@
         ticket.save_changes(self.db,
                             self.args.get('author', self.req.authname),
                             self.args.get('comment'),
+                            self.args.get('spent_work'),
                             when=now)
 
         tn = TicketNotifyEmail(self.env)
@@ -454,6 +472,14 @@
             for field in Ticket.std_fields:
                 if self.args.has_key(field) and field != 'reporter':
                     ticket[field] = self.args.get(field)
+
+            # Compute new actual work and remaining work
+            spent_work = float(self.args.get('spent_work'))
+            if spent_work > 0:
+                ticket['actual_work'] = float(ticket['actual_work']) + spent_work
+                ticket['remaining_work'] = float(ticket['planned_work']) - float(ticket['actual_work'])
+                ticket['spent_work'] = spent_work    
+
             self.req.hdf.setValue('ticket.action', action)
             reporter_id = self.args.get('author')
             comment = self.args.get('comment')
Index: templates/roadmap.cs
===================================================================
--- templates/roadmap.cs	(revision 1283)
+++ templates/roadmap.cs	(working copy)
@@ -25,6 +25,7 @@
     </p>
     <?cs with:stats = milestone.stats ?>
      <?cs if:#stats.total_tickets > #0 ?>
+      <h3> Ticket resolution progress </h3>
       <div class="progress">
        <div style="width: <?cs var:#stats.percent_complete ?>%"></div>
       </div>
@@ -38,6 +39,19 @@
          var:stats.closed_tickets ?></a></dd>
       </dl>
      <?cs /if ?>
+     <?cs if:#stats.planned_work > #0 ?>
+      <h3> Work progress </h3>
+      <div class="progress">
+       <div style="width: <?cs var:#stats.work_percent_complete ?>%"></div>
+      </div>
+      <p class="percent"><?cs var:#stats.work_percent_complete ?>%</p>     
+      <dl>
+       <dt>Planned work:</dt>
+       <dd><?cs var:stats.planned_work ?> (hrs)</dd>
+       <dt>Actual work:</dt>
+       <dd><?cs var:stats.actual_work ?> (hrs)</dd>
+      </dl>
+     <?cs /if ?>
     <?cs /with ?>
    </div>
    <div class="descr"><?cs var:milestone.descr ?></div>
Index: templates/ticket.cs
===================================================================
--- templates/ticket.cs	(revision 1283)
+++ templates/ticket.cs	(working copy)
@@ -51,6 +51,9 @@
   call:ticketprop("Status", "status", ticket.status, 0) ?><?cs
   call:ticketprop("Version", "version", ticket.version, 0) ?><?cs
   call:ticketprop("Resolution", "resolution", ticket.resolution, 0) ?><?cs
+  call:ticketprop("Planned work (hrs)", "planned_work", ticket.planned_work, 0) ?><?cs
+  call:ticketprop("Actual work (hrs)", "actual_work", ticket.actual_work, 0) ?><?cs
+  call:ticketprop("Remaining work (hrs)", "remaining_work", ticket.remaining_work, 0) ?><?cs
   call:ticketprop("Milestone", "milestone", ticket.milestone, 0) ?><?cs
   set:last_prop = #1 ?><?cs
   call:ticketprop("Keywords", "keywords", ticket.keywords, 0) ?><?cs
@@ -118,6 +121,8 @@
    <li><strong>attachment</strong> added: <?cs var:change.new ?></li><?cs
   elif $change.field == "description" ?>
    <li><strong><?cs var:change.field ?></strong> changed.</li><?cs
+  elif $change.field == "spent_work" ?>
+   <li><strong>Work spent on change</strong>: <?cs var:change.new ?> hrs</li><?cs
   elif $change.old == "" ?>
    <li><strong><?cs var:change.field ?></strong> set to <em><?cs var:change.new ?></em></li><?cs
   else ?>
@@ -191,6 +196,14 @@
    <label for="keywords">Keywords:</label>
    <input type="text" id="keywords" name="keywords" size="20"
        value="<?cs var:ticket.keywords ?>" />
+   <br />
+   <label for="planned_work">Planned work:</label>
+   <input type="text" id="planned_work" name="planned_work" size="5"
+       value="<?cs var:ticket.planned_work ?>" /> (hrs)
+   <br />
+   <label for="spent_work">Spent work:</label>
+   <input type="text" id="spent_work" name="spent_work" size="5"
+       value="<?cs var:ticket.spent_work ?>" /> (hrs)
   </div>
   <div class="col2">
    <label for="priority">Priority:</label><?cs
Index: templates/newticket.cs
===================================================================
--- templates/newticket.cs	(revision 1283)
+++ templates/newticket.cs	(working copy)
@@ -52,6 +52,10 @@
    <label for="keywords">Keywords:</label>
    <input type="text" id="keywords" name="keywords" size="20"
        value="<?cs var:newticket.keywords ?>" />
+   <br />
+   <label for="planned_work">Planned work:</label>
+   <input type="text" id="planned_work" name="planned_work" size="5"
+       value="<?cs var:newticket.planned_work ?>" /> (hrs)
   </div>
   <div class="col2">
    <label for="priority">Priority:</label><?cs

