# HG changeset patch
# Parent 3e22fab66b4f204eab1db2ae28b8cfeb968b041e
Add code from the stagnant private branch in rblank's Hg repo for
TicketCustomTimeFields support, see
 https://bitbucket.org/rblank/trac/compare/ticket-1942..jquery-ui-datetimepicker
.

diff --git a/trac/ticket/api.py b/trac/ticket/api.py
--- a/trac/ticket/api.py
+++ b/trac/ticket/api.py
@@ -30,6 +30,28 @@
 from trac.wiki import IWikiSyntaxProvider, WikiParser
 
 
+class TicketFieldList(list):
+    """Improved ticket field list, allowing access by name."""
+    __slots__ = ['_map']
+
+    def __init__(self, *args):
+        super(TicketFieldList, self).__init__(*args)
+        self._map = dict((value['name'], value) for value in self)
+
+    def append(self, value):
+        super(TicketFieldList, self).append(value)
+        self._map[value['name']] = value
+
+    def by_name(self, name, default=None):
+        return self._map.get(name, default)
+
+    def __copy__(self):
+        return TicketFieldList(self)
+
+    def __deepcopy__(self, memo):
+        return TicketFieldList(copy.deepcopy(value, memo) for value in self)
+
+
 class ITicketActionController(Interface):
     """Extension point interface for components willing to participate
     in the ticket workflow.
@@ -280,7 +302,7 @@
         """Return the list of fields available for tickets."""
         from trac.ticket import model
 
-        fields = []
+        fields = TicketFieldList()
 
         # Basic text fields
         fields.append({'name': 'summary', 'type': 'text',
@@ -329,12 +351,12 @@
         fields.append({'name': 'cc', 'type': 'text', 'label': N_('Cc')})
 
         # Date/time fields
-        fields.append({'name': 'time', 'type': 'time',
+        fields.append({'name': 'time', 'type': 'time', 'format': 'age',
                        'label': N_('Created')})
-        fields.append({'name': 'changetime', 'type': 'time',
+        fields.append({'name': 'changetime', 'type': 'time', 'format': 'age',
                        'label': N_('Modified')})
 
-        for field in self.get_custom_fields():
+        for field in self.custom_fields:
             if field['name'] in [f['name'] for f in fields]:
                 self.log.warning('Duplicate field name "%s" (ignoring)',
                                  field['name'])
@@ -347,7 +369,6 @@
                 self.log.warning('Invalid name for custom field: "%s" '
                                  '(ignoring)', field['name'])
                 continue
-            field['custom'] = True
             fields.append(field)
 
         return fields
@@ -362,12 +383,12 @@
     @cached
     def custom_fields(self, db):
         """Return the list of custom ticket fields available for tickets."""
-        fields = []
+        fields = TicketFieldList()
         config = self.ticket_custom_section
         for name in [option for option, value in config.options()
                      if '.' not in option]:
             field = {
-                'name': name,
+                'name': name, 'custom': True,
                 'type': config.get(name),
                 'order': config.getint(name + '.order', 0),
                 'label': config.get(name + '.label') or name.capitalize(),
@@ -384,6 +405,8 @@
                 field['format'] = config.get(name + '.format', 'plain')
                 field['width'] = config.getint(name + '.cols')
                 field['height'] = config.getint(name + '.rows')
+            elif field['type'] == 'time':
+                field['format'] = config.get(name + '.format', 'datetime')
             fields.append(field)
 
         fields.sort(lambda x, y: cmp((x['order'], x['name']),
diff --git a/trac/ticket/model.py b/trac/ticket/model.py
--- a/trac/ticket/model.py
+++ b/trac/ticket/model.py
@@ -28,7 +28,8 @@
 from trac.ticket.api import TicketSystem
 from trac.util import embedded_numbers, partition
 from trac.util.text import empty
-from trac.util.datefmt import from_utimestamp, to_utimestamp, utc, utcmax
+from trac.util.datefmt import from_utimestamp, parse_date, to_utimestamp, \
+                              utc, utcmax
 from trac.util.translation import _
 
 __all__ = ['Ticket', 'Type', 'Status', 'Resolution', 'Priority', 'Severity',
@@ -44,6 +45,25 @@
     return ', '.join(cclist)
 
 
+def _str_to_datetime(value):
+    if value is None:
+        return None
+    try:
+        return from_utimestamp(long(value))
+    except ValueError:
+        pass
+    try:
+        return parse_date(value.strip(), utc, 'datetime')
+    except Exception:
+        return None
+
+
+def _datetime_to_str(dt):
+    if dt:
+        return str(to_utimestamp(dt))
+    return ''
+
+
 class Ticket(object):
 
     # Fields that must not be modified directly by the user
@@ -133,7 +153,9 @@
                 SELECT name, value FROM ticket_custom WHERE ticket=%s
                 """, (tkt_id,)):
             if name in self.custom_fields:
-                if value is None:
+                if name in self.time_fields:
+                    self.values[name] = _str_to_datetime(value)
+                elif value is None:
                     self.values[name] = empty
                 else:
                     self.values[name] = value
@@ -150,11 +172,10 @@
             self._old[name] = self.values.get(name)
         elif self._old[name] == value: # Change of field reverted
             del self._old[name]
-        if value:
+        if value and name not in self.time_fields:
             if isinstance(value, list):
                 raise TracError(_("Multi-values fields not supported yet"))
-            field = [field for field in self.fields if field['name'] == name]
-            if field and field[0].get('type') != 'textarea':
+            if self.fields.by_name(name, {}).get('type') != 'textarea':
                 value = value.strip()
         self.values[name] = value
 
@@ -165,9 +186,7 @@
             value = self.values[name]
             if value is not empty:
                 return value
-            field = [field for field in self.fields if field['name'] == name]
-            if field:
-                return field[0].get('value', '')
+            return self.fields.by_name(name, {}).get('value', '')
         except KeyError:
             pass
 
@@ -175,7 +194,7 @@
         """Populate the ticket with 'suitable' values from a dictionary"""
         field_names = [f['name'] for f in self.fields]
         for name in [name for name in values.keys() if name in field_names]:
-            self[name] = values.get(name, '')
+            self[name] = values[name]
 
         # We have to do an extra trick to catch unchecked checkboxes
         for name in [name for name in values.keys() if name[9:] in field_names
@@ -214,10 +233,7 @@
             self['owner'] = default_to_owner
 
         # Perform type conversions
-        values = dict(self.values)
-        for field in self.time_fields:
-            if field in values:
-                values[field] = to_utimestamp(values[field])
+        values = self._to_db_types(self.values)
 
         # Insert ticket record
         std_fields = []
@@ -241,9 +257,9 @@
             if custom_fields:
                 db.executemany(
                     """INSERT INTO ticket_custom (ticket, name, value)
-                      VALUES (%s, %s, %s)
-                      """,
-                    [(tkt_id, c, self[c]) for c in custom_fields])
+                       VALUES (%s, %s, %s)
+                       """, [(tkt_id, c, values.get(c, ''))
+                             for c in custom_fields])
 
         self.id = tkt_id
         self.resource = self.resource(id=tkt_id)
@@ -297,6 +313,10 @@
                     # we just leave the owner as is.
                     pass
 
+        # Perform type conversions
+        values = self._to_db_types(self.values)
+        old_values = self._to_db_types(self._old)
+
         with self.env.db_transaction as db:
             db("UPDATE ticket SET changetime=%s WHERE id=%s",
                (when_ts, self.id))
@@ -330,20 +350,20 @@
                                      """, (self.id, name)):
                         db("""UPDATE ticket_custom SET value=%s
                               WHERE ticket=%s AND name=%s
-                              """, (self[name], self.id, name))
+                              """, (values.get(name, ''), self.id, name))
                         break
                     else:
                         db("""INSERT INTO ticket_custom (ticket,name,value)
                               VALUES(%s,%s,%s)
-                              """, (self.id, name, self[name]))
+                              """, (self.id, name, values.get(name, '')))
                 else:
                     db("UPDATE ticket SET %s=%%s WHERE id=%%s" 
-                       % name, (self[name], self.id))
+                       % name, (values.get(name, ''), self.id))
                 db("""INSERT INTO ticket_change
                         (ticket,time,author,field,oldvalue,newvalue)
                       VALUES (%s, %s, %s, %s, %s, %s)
-                      """, (self.id, when_ts, author, name, self._old[name],
-                            self[name]))
+                      """, (self.id, when_ts, author, name, old_values[name],
+                            values.get(name, '')))
 
             # always save comment, even if empty 
             # (numbering support for timeline)
@@ -360,6 +380,16 @@
             listener.ticket_changed(self, comment, author, old_values)
         return int(cnum.rsplit('.', 1)[-1])
 
+    def _to_db_types(self, values):
+        values = values.copy()
+        for field, value in values.iteritems():
+            if field in self.time_fields:
+                if field in self.custom_fields:
+                    values[field] = _datetime_to_str(value)
+                else:
+                    values[field] = to_utimestamp(value)
+        return values
+
     def get_changelog(self, when=None, db=None):
         """Return the changelog as a list of tuples of the form
         (time, author, field, oldvalue, newvalue, permanent).
@@ -403,10 +433,15 @@
                 ORDER BY time,permanent,author
                 """
             args = (self.id, sid, sid)
-        return [(from_utimestamp(t), author, field, oldvalue or '',
-                 newvalue or '', permanent)
-                for t, author, field, oldvalue, newvalue, permanent in
-                self.env.db_query(sql, args)]
+        log = []
+        for t, author, field, oldvalue, newvalue, permanent \
+                in self.env.db_query(sql, args):
+            if field in self.time_fields:
+                oldvalue = _str_to_datetime(oldvalue)
+                newvalue = _str_to_datetime(newvalue)
+            log.append((from_utimestamp(t), author, field,
+                        oldvalue or '', newvalue or '', permanent))
+        return log
 
     def delete(self, db=None):
         """Delete the ticket.
diff --git a/trac/ticket/notification.py b/trac/ticket/notification.py
--- a/trac/ticket/notification.py
+++ b/trac/ticket/notification.py
@@ -26,7 +26,8 @@
 from trac.config import *
 from trac.notification import NotifyEmail
 from trac.ticket.api import TicketSystem
-from trac.util.datefmt import to_utimestamp
+from trac.util.datefmt import format_date, format_datetime, timezone, \
+                              to_utimestamp
 from trac.util.text import obfuscate_email_address, text_width, wrap
 from trac.util.translation import deactivate, reactivate
 
@@ -162,6 +163,10 @@
                         if field in ['owner', 'reporter']:
                             old = obfuscate_email_address(old)
                             new = obfuscate_email_address(new)
+                        elif field in ticket.time_fields:
+                            format = ticket.fields.by_name(field).get('format')
+                            old = self.format_time_field(old, format)
+                            new = self.format_time_field(new, format)
                         newv = new
                         length = 7 + len(field)
                         spacer_old, spacer_new = ' ', ' '
@@ -220,6 +225,9 @@
             if not fname in tkt.values:
                 continue
             fval = tkt[fname] or ''
+            if fname in tkt.time_fields:
+                format = tkt.fields.by_name(fname).get('format')
+                fval = self.format_time_field(fval, format)
             if fval.find('\n') != -1:
                 continue
             if fname in ['owner', 'reporter']:
@@ -253,6 +261,9 @@
             if not tkt.values.has_key(fname):
                 continue
             fval = tkt[fname] or ''
+            if fname in tkt.time_fields:
+                format = tkt.fields.by_name(fname).get('format')
+                fval = self.format_time_field(fval, format)
             if fname in ['owner', 'reporter']:
                 fval = obfuscate_email_address(fval)
             if f['type'] == 'textarea' or '\n' in unicode(fval):
@@ -320,6 +331,16 @@
         
         return template.generate(**data).render('text', encoding=None).strip()
 
+    def format_time_field(self, value, format):
+        try:
+            tzinfo = timezone(self.config.get('trac', 'default_timezone'))
+        except KeyError:
+            tzinfo = None
+        if format == 'date':
+            return format_date(value, tzinfo=tzinfo) if value else ''
+        else:
+            return format_datetime(value, tzinfo=tzinfo) if value else ''
+
     def get_recipients(self, tktid):
         notify_reporter = self.config.getbool('notification',
                                               'always_notify_reporter')
diff --git a/trac/ticket/query.py b/trac/ticket/query.py
--- a/trac/ticket/query.py
+++ b/trac/ticket/query.py
@@ -34,8 +34,9 @@
 from trac.ticket.api import TicketSystem
 from trac.ticket.model import Milestone, group_milestones
 from trac.util import Ranges, as_bool
-from trac.util.datefmt import format_datetime, from_utimestamp, parse_date, \
-                              to_timestamp, to_utimestamp, utc, user_time
+from trac.util.datefmt import format_date, format_datetime, from_utimestamp, \
+                              parse_date, pretty_timedelta, to_timestamp, \
+                              to_utimestamp, utc, user_time
 from trac.util.presentation import Paginator
 from trac.util.text import empty, shorten_line, quote_query_string
 from trac.util.translation import _, tag_, cleandoc_
@@ -316,10 +317,7 @@
             # self.env.log.debug("SQL: " + sql % tuple([repr(a) for a in args]))
             cursor.execute(sql, args)
             columns = get_column_names(cursor)
-            fields = []
-            for column in columns:
-                fields += [f for f in self.fields if f['name'] == column] or \
-                          [None]
+            fields = [self.fields.by_name(column, None) for column in columns]
             results = []
 
             column_indices = range(len(columns))
@@ -334,7 +332,7 @@
                         if href is not None:
                             result['href'] = href.ticket(val)
                     elif name in self.time_fields:
-                        val = from_utimestamp(val)
+                        val = from_utimestamp(long(val)) if val else ''
                     elif field and field['type'] == 'checkbox':
                         try:
                             val = bool(int(val))
@@ -711,12 +709,10 @@
 
         cols = self.get_columns()
         labels = TicketSystem(self.env).get_ticket_field_labels()
-        wikify = set(f['name'] for f in self.fields 
-                     if f['type'] == 'text' and f.get('format') == 'wiki')
 
         headers = [{
             'name': col, 'label': labels.get(col, _('Ticket')),
-            'wikify': col in wikify,
+            'field': self.fields.by_name(col, {}),
             'href': self.get_href(context.href, order=col,
                                   desc=(col == self.order and not self.desc))
         } for col in cols]
@@ -1071,7 +1067,7 @@
                 add_warning(req, error)
 
         context = web_context(req, 'query')
-        owner_field = [f for f in query.fields if f['name'] == 'owner']
+        owner_field = query.fields.by_name('owner', None)
         if owner_field:
             TicketSystem(self.env).eventually_restrict_owner(owner_field[0])
         data = query.template_data(context, tickets, orig_list, orig_time, req)
@@ -1141,8 +1137,16 @@
                         value = Chrome(self.env).format_emails(
                                     context.child(ticket), value)
                     elif col in query.time_fields:
-                        value = format_datetime(value, '%Y-%m-%d %H:%M:%S',
-                                                tzinfo=req.tz)
+                        format = query.fields.by_name(col).get('format')
+                        if format == 'age':
+                            value = pretty_timedelta(value) if value else ''
+                        elif format == 'date':
+                            value = format_date(value, '%Y-%m-%d',
+                                                tzinfo=req.tz) if value else ''
+                        else:
+                            value = format_datetime(value, '%Y-%m-%d %H:%M:%S',
+                                                    tzinfo=req.tz) \
+                                    if value else ''
                     values.append(unicode(value).encode('utf-8'))
                 writer.writerow(values)
         return (content.getvalue(), '%s;charset=utf-8' % mimetype)
diff --git a/trac/ticket/templates/query_results.html b/trac/ticket/templates/query_results.html
--- a/trac/ticket/templates/query_results.html
+++ b/trac/ticket/templates/query_results.html
@@ -75,12 +75,22 @@
                         class="${classes(closed=result.status == 'closed')}">#$result.id</a></td>
                     <td py:otherwise="" class="$name" py:choose="">
                       <a py:when="name == 'summary'" href="$result.href" title="View ticket">$value</a>
+                      <py:when test="isinstance(value, datetime)">
+                        <py:choose test="header.field.format">
+                          <py:when test="'age'">${dateinfo(value)}</py:when>
+                          <py:when test="'date'">${format_date(value, tzinfo=req.tz)}</py:when>
+                          <py:otherwise>${format_datetime(value, tzinfo=req.tz)}</py:otherwise>
+                        </py:choose>
+                      </py:when>
+<!--!
                       <py:when test="isinstance(value, datetime)">${pretty_dateinfo(value, dateonly=True)}</py:when>
+-->
                       <py:when test="name == 'reporter'">${authorinfo(value)}</py:when>
                       <py:when test="name == 'cc'">${format_emails(ticket_context, value)}</py:when>
                       <py:when test="name == 'owner' and value">${authorinfo(value)}</py:when>
                       <py:when test="name == 'milestone'"><a py:if="value" title="View milestone" href="${href.milestone(value)}">${value}</a></py:when>
-                      <py:when test="header.wikify">${wiki_to_oneliner(ticket_context, value)}</py:when>
+                      <py:when test="header.field.type == 'text'
+                                     and header.field.format == 'wiki'">${wiki_to_oneliner(ticket_context, value)}</py:when>
                       <py:otherwise>$value</py:otherwise>
                     </td>
                   </py:with>
diff --git a/trac/ticket/templates/ticket.html b/trac/ticket/templates/ticket.html
--- a/trac/ticket/templates/ticket.html
+++ b/trac/ticket/templates/ticket.html
@@ -265,23 +265,25 @@
                                  checked="${value == option or None}" />
                           ${option}
                         </label>
+                        <input py:when="'time'" type="text" id="field-${field.name}" title="${field.format_hint}"
+                               name="field_${field.name}" value="${field.edit}" />
                         <py:otherwise><!--! Text input fields -->
                           <py:choose>
                             <span py:when="field.cc_entry"><!--! Special case for Cc: field -->
                               <em>${field.cc_entry}</em>
                               <input type="checkbox" id="field-cc" name="cc_update"
-                                title="This checkbox allows you to add or remove yourself from the CC list."
-                                checked="${field.cc_update}" />
+                                     title="This checkbox allows you to add or remove yourself from the CC list."
+                                     checked="${field.cc_update}" />
                             </span>
                             <!--! Cc: when TICKET_EDIT_CC is allowed -->
                             <span py:when="field.name == 'cc'">
-                              <input  type="text" id="field-${field.name}"
-                                title="Space or comma delimited email addresses and usernames are accepted."
-                                name="field_${field.name}" value="${value}" />
+                              <input type="text" id="field-${field.name}"
+                                     title="Space or comma delimited email addresses and usernames are accepted."
+                                     name="field_${field.name}" value="${value}" />
                             </span>
                             <!--! All the other text input fields -->
                             <input py:otherwise="" type="text" id="field-${field.name}"
-                              name="field_${field.name}" value="${value}" />
+                                   name="field_${field.name}" value="${value}" />
                           </py:choose>
                         </py:otherwise>
                       </py:choose>
diff --git a/trac/ticket/tests/api.py b/trac/ticket/tests/api.py
--- a/trac/ticket/tests/api.py
+++ b/trac/ticket/tests/api.py
@@ -31,7 +31,8 @@
         self.env.config.set('ticket-custom', 'test.format', 'wiki')
         fields = TicketSystem(self.env).get_custom_fields()
         self.assertEqual({'name': 'test', 'type': 'text', 'label': 'Test',
-                          'value': 'Foo bar', 'order': 0, 'format': 'wiki'},
+                          'value': 'Foo bar', 'order': 0, 'format': 'wiki',
+                          'custom': True},
                          fields[0])
 
     def test_custom_field_select(self):
@@ -42,7 +43,7 @@
         fields = TicketSystem(self.env).get_custom_fields()
         self.assertEqual({'name': 'test', 'type': 'select', 'label': 'Test',
                           'value': '1', 'options': ['option1', 'option2'],
-                          'order': 0},
+                          'order': 0, 'custom': True},
                          fields[0])
 
     def test_custom_field_optional_select(self):
@@ -53,7 +54,7 @@
         fields = TicketSystem(self.env).get_custom_fields()
         self.assertEqual({'name': 'test', 'type': 'select', 'label': 'Test',
                           'value': '1', 'options': ['option1', 'option2'],
-                          'order': 0, 'optional': True},
+                          'order': 0, 'optional': True, 'custom': True},
                          fields[0])
 
     def test_custom_field_textarea(self):
@@ -66,7 +67,7 @@
         fields = TicketSystem(self.env).get_custom_fields()
         self.assertEqual({'name': 'test', 'type': 'textarea', 'label': 'Test',
                           'value': 'Foo bar', 'width': 60, 'height': 4,
-                          'order': 0, 'format': 'wiki'},
+                          'order': 0, 'format': 'wiki', 'custom': True},
                          fields[0])
 
     def test_custom_field_order(self):
diff --git a/trac/ticket/web_ui.py b/trac/ticket/web_ui.py
--- a/trac/ticket/web_ui.py
+++ b/trac/ticket/web_ui.py
@@ -37,8 +37,9 @@
 from trac.ticket.notification import TicketNotifyEmail
 from trac.timeline.api import ITimelineEventProvider
 from trac.util import as_bool, as_int, get_reporter_id
-from trac.util.datefmt import format_datetime, from_utimestamp, \
-                              to_utimestamp, utc
+from trac.util.datefmt import format_datetime, format_date, format_datetime, from_utimestamp, \
+                              get_date_format_hint, get_datetime_format_hint, \
+                              pretty_timedelta, parse_date, to_utimestamp, utc
 from trac.util.text import exception_to_unicode, obfuscate_email_address, \
                            shorten_line, to_unicode
 from trac.util.presentation import separated
@@ -702,6 +703,10 @@
         for each in Ticket.protected_fields:
             fields.pop(each, None)
             fields.pop('checkbox_' + each, None)    # See Ticket.populate()
+        for field, value in fields.iteritems():
+            if field in ticket.time_fields:
+                fields[field] = parse_date(value, req.tz, 'datetime') \
+                                if value else None
         ticket.populate(fields)
         # special case for updating the Cc: field
         if 'cc_update' in req.args:
@@ -1056,8 +1061,8 @@
             if name in ('cc', 'reporter'):
                 value = Chrome(self.env).format_emails(context, value, ' ')
             elif name in ticket.time_fields:
-                value = format_datetime(value, '%Y-%m-%d %H:%M:%S',
-                                        tzinfo=req.tz)
+                format = ticket.fields.by_name(name).get('format')
+                value = self._render_time_field(req, value, format)
             cols.append(value.encode('utf-8'))
         writer.writerow(cols)
         return (content.getvalue(), '%s;charset=utf-8' % mimetype)
@@ -1196,6 +1201,8 @@
             # Shouldn't happen in "normal" circumstances, hence not a warning
             raise InvalidTicket(_("Invalid comment threading identifier"))
 
+        # FIXME: Validate time field content
+
         # Custom validation rules
         for manipulator in self.ticket_manipulators:
             for field, message in manipulator.validate_ticket(req, ticket):
@@ -1362,7 +1369,7 @@
             type_ = field['type']
  
             # enable a link to custom query for all choice fields
-            if type_ not in ['text', 'textarea']:
+            if type_ not in ['text', 'textarea', 'time']:
                 field['rendered'] = self._query_link(req, name, ticket[name])
 
             # per field settings
@@ -1437,6 +1444,16 @@
                     field['rendered'] = \
                         format_to_html(self.env, context, ticket[name],
                                 escape_newlines=self.must_preserve_newlines)
+            elif type_ == 'time':
+                value = ticket[name]
+                format = field.get('format', 'datetime')
+                field['rendered'] = self._render_time_field(req, value, format,
+                                                            relative=True)
+                field['edit'] = self._render_time_field(req, value, format)
+                if format == 'date':
+                    field['format_hint'] = get_date_format_hint()
+                else:
+                    field['format_hint'] = get_datetime_format_hint()
             
             # ensure sane defaults
             field.setdefault('optional', False)
@@ -1449,7 +1466,7 @@
             fields.remove(owner_field)
             fields.append(owner_field)
         return fields
-        
+
     def _insert_ticket_data(self, req, ticket, data, author_id, field_changes):
         """Insert ticket data into the template `data`"""
         replyto = req.args.get('replyto')
@@ -1602,16 +1619,16 @@
                                                   resource_new)
             if rendered:
                 changes['rendered'] = rendered
+            elif ticket.fields.by_name(field, {}).get('type') == 'time':
+                format = ticket.fields.by_name(field).get('format')
+                changes['old'] = self._render_time_field(req, old, format)
+                changes['new'] = self._render_time_field(req, new, format)
 
     def _render_property_diff(self, req, ticket, field, old, new, 
                               resource_new=None):
         rendered = None
         # per type special rendering of diffs
-        type_ = None
-        for f in ticket.fields:
-            if f['name'] == field:
-                type_ = f['type']
-                break
+        type_ = ticket.fields.by_name(field, {}).get('type')
         if type_ == 'checkbox':
             rendered = _("set") if new == '1' else _("unset")
         elif type_ == 'textarea':
@@ -1662,6 +1679,17 @@
                                 old=tag.em(old), new=tag.em(new))
         return rendered
 
+    def _render_time_field(self, req, value, format, relative=False):
+        format = format or 'datetime'
+        if format == 'age' and relative:
+            return pretty_timedelta(value) if value else ''
+        elif format == 'date':
+            return format_date(value, '%Y-%m-%d', tzinfo=req.tz) \
+                   if value else ''
+        else:
+            return format_datetime(value, '%Y-%m-%d %H:%M:%S', tzinfo=req.tz) \
+                   if value else ''
+
     def grouped_changelog_entries(self, ticket, db=None, when=None):
         """Iterate on changelog entries, consolidating related changes
         in a `dict` object.
diff --git a/trac/util/datefmt.py b/trac/util/datefmt.py
--- a/trac/util/datefmt.py
+++ b/trac/util/datefmt.py
@@ -513,10 +513,12 @@
     t = tzinfo.localize(datetime(*(values[k] for k in 'yMdhms')))
     return tzinfo.normalize(t)
 
-_REL_TIME_RE = re.compile(
-    r'(\d+\.?\d*)\s*'
-    r'(second|minute|hour|day|week|month|year|[hdwmy])s?\s*'
-    r'(?:ago)?$')
+_REL_FUTURE_RE = re.compile(
+    r'(?:in|\+)\s*(\d+\.?\d*)\s*'
+    r'(second|minute|hour|day|week|month|year|[hdwmy])s?$')
+_REL_PAST_RE = re.compile(
+    r'(?:-\s*)?(\d+\.?\d*)\s*'
+    r'(second|minute|hour|day|week|month|year|[hdwmy])s?\s*(?:ago)?$')
 _time_intervals = dict(
     second=lambda v: timedelta(seconds=v),
     minute=lambda v: timedelta(minutes=v),
@@ -531,7 +533,7 @@
     m=lambda v: timedelta(days=30 * v),
     y=lambda v: timedelta(days=365 * v),
 )
-_TIME_START_RE = re.compile(r'(this|last)\s*'
+_TIME_START_RE = re.compile(r'(this|last|next)\s*'
                             r'(second|minute|hour|day|week|month|year)$')
 _time_starts = dict(
     second=lambda now: now.replace(microsecond=0),
@@ -543,7 +545,7 @@
     month=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0,
                                   day=1),
     year=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0,
-                                  day=1, month=1),
+                                 day=1, month=1),
 )
 
 def _parse_relative_time(text, tzinfo):
@@ -555,13 +557,20 @@
     if text == 'yesterday':
         return now.replace(microsecond=0, second=0, minute=0, hour=0) \
                - timedelta(days=1)
-    match = _REL_TIME_RE.match(text)
+    if text == 'tomorrow':
+        return now.replace(microsecond=0, second=0, minute=0, hour=0) \
+               + timedelta(days=1)
+    match = _REL_FUTURE_RE.match(text)
     if match:
-        (value, interval) = match.groups()
+        value, interval = match.groups()
+        return now + _time_intervals[interval](float(value))
+    match = _REL_PAST_RE.match(text)
+    if match:
+        value, interval = match.groups()
         return now - _time_intervals[interval](float(value))
     match = _TIME_START_RE.match(text)
     if match:
-        (which, start) = match.groups()
+        which, start = match.groups()
         dt = _time_starts[start](now)
         if which == 'last':
             if start == 'month':
@@ -571,8 +580,15 @@
                     dt = dt.replace(year=dt.year - 1, month=12)
             else:
                 dt -= _time_intervals[start](1)
+        elif which == 'next':
+            if start == 'month':
+                if dt.month < 12:
+                    dt = dt.replace(month=dt.month + 1)
+                else:
+                    dt = dt.replace(year=dt.year + 1, month=1)
+            else:
+                dt += _time_intervals[start](1)
         return dt
-    return None
 
 # -- formatting/parsing helper functions
 

