diff --git a/trac/htdocs/css/report.css b/trac/htdocs/css/report.css
--- a/trac/htdocs/css/report.css
+++ b/trac/htdocs/css/report.css
@@ -38,7 +38,6 @@
 #filters td label { font-size: 11px }
 #filters td.mode { text-align: right }
 #filters td.filter { width: 100% }
-#filters td.filter label { padding-right: 1em }
 #filters td.actions { text-align: right; white-space: nowrap }
 
 #columns div label { 
diff --git a/trac/htdocs/js/query.js b/trac/htdocs/js/query.js
--- a/trac/htdocs/js/query.js
+++ b/trac/htdocs/js/query.js
@@ -225,13 +225,28 @@
         td.className = "filter";
         if (property.type == "select") {
           var element = createSelect(propertyName, property.options, true);
+          td.appendChild(element);
         } else if (property.type == "text") {
           var element = document.createElement("input");
           element.type = "text";
           element.name = propertyName;
           element.size = 42;
+          td.appendChild(element);
+        } else if (property.type == "time") {
+          var element = document.createElement("input");
+          element.type = "text";
+          element.name = propertyName;
+          element.size = 14;
+          td.appendChild(element);
+          td.appendChild(document.createTextNode(" "));
+          td.appendChild(createLabel("and"));
+          td.appendChild(document.createTextNode(" "));
+          var element2 = document.createElement("input");
+          element2.type = "text";
+          element2.name = propertyName + "_end";
+          element2.size = 14;
+          td.appendChild(element2);
         }
-        td.appendChild(element);
         element.focus();
         tr.appendChild(td);
       }
diff --git a/trac/ticket/api.py b/trac/ticket/api.py
--- a/trac/ticket/api.py
+++ b/trac/ticket/api.py
@@ -240,6 +240,12 @@
             field = {'name': name, 'type': 'text', 'label': name.title()}
             fields.append(field)
 
+        # Date/time fields
+        fields.append({'name': 'time', 'type': 'time',
+                       'label': _('Created')})
+        fields.append({'name': 'changetime', 'type': 'time',
+                       'label': _('Modified')})
+
         for field in self.get_custom_fields():
             if field['name'] in [f['name'] for f in fields]:
                 self.log.warning('Duplicate field name "%s" (ignoring)',
@@ -278,6 +284,11 @@
 
         fields.sort(lambda x, y: cmp(x['order'], y['order']))
         return fields
+
+    def get_field_synonyms(self):
+        """Return a mapping from field name synonyms to field names.
+        The synonyms are supposed to be more intuitive for custom queries."""
+        return {'created': 'time', 'modified': 'changetime'}
 
     # IPermissionRequestor methods
 
diff --git a/trac/ticket/model.py b/trac/ticket/model.py
--- a/trac/ticket/model.py
+++ b/trac/ticket/model.py
@@ -40,16 +40,22 @@
     def id_is_valid(num):
         return 0 < int(num) <= 1L << 31
 
+    # 0.11 compatibility
+    time_created = property(lambda self: self.values.get('time'))
+    time_changed = property(lambda self: self.values.get('changetime'))
+    
     def __init__(self, env, tkt_id=None, db=None, version=None):
         self.env = env
         self.resource = Resource('ticket', tkt_id, version)
         self.fields = TicketSystem(self.env).get_ticket_fields()
+        self.time_fields = [f['name'] for f in self.fields
+                            if f['type'] == 'time']
         self.values = {}
         if tkt_id is not None:
             self._fetch_ticket(tkt_id, db)
         else:
             self._init_defaults(db)
-            self.id = self.time_created = self.time_changed = None
+            self.id = None
         self._old = {}
 
     def _get_db(self, db):
@@ -66,7 +72,7 @@
     def _init_defaults(self, db=None):
         for field in self.fields:
             default = None
-            if field['name'] in ['resolution', 'status']:
+            if field['name'] in ['resolution', 'status', 'time', 'changetime']:
                 # Ignore for new - only change through workflow
                 pass
             elif not field.get('custom'):
@@ -93,7 +99,7 @@
             # Fetch the standard ticket fields
             std_fields = [f['name'] for f in self.fields if not f.get('custom')]
             cursor = db.cursor()
-            cursor.execute("SELECT %s,time,changetime FROM ticket WHERE id=%%s"
+            cursor.execute("SELECT %s FROM ticket WHERE id=%%s"
                            % ','.join(std_fields), (tkt_id,))
             row = cursor.fetchone()
         if not row:
@@ -102,9 +108,11 @@
 
         self.id = tkt_id
         for i in range(len(std_fields)):
-            self.values[std_fields[i]] = row[i] or ''
-        self.time_created = datetime.fromtimestamp(row[len(std_fields)], utc)
-        self.time_changed = datetime.fromtimestamp(row[len(std_fields) + 1], utc)
+            field = std_fields[i]
+            if field in self.time_fields:
+                self.values[field] = datetime.fromtimestamp(row[i], utc)
+            else:
+                self.values[field] = row[i] or ''
 
         # Fetch custom fields if available
         custom_fields = [f['name'] for f in self.fields if f.get('custom')]
@@ -164,7 +172,7 @@
         # Add a timestamp
         if when is None:
             when = datetime.now(utc)
-        self.time_created = self.time_changed = when
+        self.values['time'] = self.values['changetime'] = when
 
         cursor = db.cursor()
 
@@ -178,9 +186,13 @@
                 # No such component exists
                 pass
 
+        # Perform type conversions
+        values = dict(self.values)
+        for field in self.time_fields:
+            if field in values:
+                values[field] = to_timestamp(values[field])
+        
         # Insert ticket record
-        created = to_timestamp(self.time_created)
-        changed = to_timestamp(self.time_changed)
         std_fields = []
         custom_fields = []
         for f in self.fields:
@@ -190,10 +202,10 @@
                     custom_fields.append(fname)
                 else:
                     std_fields.append(fname)
-        cursor.execute("INSERT INTO ticket (%s,time,changetime) VALUES (%s)"
+        cursor.execute("INSERT INTO ticket (%s) VALUES (%s)"
                        % (','.join(std_fields),
-                          ','.join(['%s'] * (len(std_fields) + 2))),
-                       [self[name] for name in std_fields] + [created, changed])
+                          ','.join(['%s'] * len(std_fields))),
+                       [values[name] for name in std_fields])
         tkt_id = db.get_last_id(cursor, 'ticket')
 
         # Insert custom fields
@@ -289,7 +301,7 @@
             db.commit()
         old_values = self._old
         self._old = {}
-        self.time_changed = when
+        self.values['changetime'] = when
 
         for listener in TicketSystem(self.env).change_listeners:
             listener.ticket_changed(self, comment, author, old_values)
diff --git a/trac/ticket/notification.py b/trac/ticket/notification.py
--- a/trac/ticket/notification.py
+++ b/trac/ticket/notification.py
@@ -149,7 +149,8 @@
 
     def format_props(self):
         tkt = self.ticket
-        fields = [f for f in tkt.fields if f['name'] not in ('summary', 'cc')]
+        fields = [f for f in tkt.fields 
+                  if f['name'] not in ('summary', 'cc', 'time', 'changetime')]
         width = [0, 0, 0, 0]
         i = 0
         for f in [f['name'] for f in fields if f['type'] != 'textarea']:
diff --git a/trac/ticket/query.py b/trac/ticket/query.py
--- a/trac/ticket/query.py
+++ b/trac/ticket/query.py
@@ -31,7 +31,7 @@
 from trac.resource import Resource
 from trac.ticket.api import TicketSystem
 from trac.util import Ranges
-from trac.util.datefmt import to_timestamp, utc
+from trac.util.datefmt import format_datetime, parse_date, to_timestamp, utc
 from trac.util.presentation import Paginator
 from trac.util.text import shorten_line
 from trac.util.translation import _, tag_
@@ -56,7 +56,8 @@
         self.env = env
         self.id = report # if not None, it's the corresponding saved query
         self.constraints = constraints or {}
-        self.order = order
+        synonyms = TicketSystem(self.env).get_field_synonyms()
+        self.order = synonyms.get(order, order)     # 0.11 compatibility
         self.desc = desc
         self.group = group
         self.groupdesc = groupdesc
@@ -100,20 +101,14 @@
         if verbose and 'description' not in rows: # 0.10 compatibility
             rows.append('description')
         self.fields = TicketSystem(self.env).get_ticket_fields()
+        self.time_fields = [f['name'] for f in self.fields
+                            if f['type'] == 'time']
         field_names = [f['name'] for f in self.fields]
         self.cols = [c for c in cols or [] if c in field_names or 
-                     c in ('id', 'time', 'changetime')]
+                     c == 'id']
         self.rows = [c for c in rows if c in field_names]
         if self.order != 'id' and self.order not in field_names:
-            # TODO: fix after adding time/changetime to the api.py
-            if order == 'created':
-                order = 'time'
-            elif order == 'modified':
-                order = 'changetime'
-            if order in ('time', 'changetime'):
-                self.order = order
-            else:
-                self.order = 'priority'
+            self.order = 'priority'
 
         if self.group not in field_names:
             self.group = None
@@ -124,6 +119,7 @@
         kw_strs = ['order', 'group', 'page', 'max']
         kw_arys = ['rows']
         kw_bools = ['desc', 'groupdesc', 'verbose']
+        synonyms = TicketSystem(env).get_field_synonyms()
         constraints = {}
         cols = []
         for filter_ in filters:
@@ -131,7 +127,7 @@
             if len(filter_) != 2:
                 raise QuerySyntaxError(_('Query filter requires field and ' 
                                          'constraints separated by a "="'))
-            field,values = filter_
+            field, values = filter_
             if not field:
                 raise QuerySyntaxError(_('Query filter requires field name'))
             # from last char of `field`, get the mode of comparison
@@ -156,9 +152,11 @@
                 elif field in kw_bools:
                     kw[field] = True
                 elif field == 'col':
-                    cols.extend(processed_values)
+                    cols.extend(synonyms.get(value, value)
+                                for value in processed_values)
                 else:
-                    constraints[field] = processed_values
+                    constraints.setdefault(synonyms.get(field, field), 
+                                           []).extend(processed_values)
             except UnicodeError:
                 pass # field must be a str, see `get_href()`
         report = constraints.pop('report', None)
@@ -184,8 +182,6 @@
             if col in cols:
                 cols.remove(col)
                 cols.append(col)
-        # TODO: fix after adding time/changetime to the api.py
-        cols += ['time', 'changetime']
 
         # Semi-intelligently remove columns that are restricted to a single
         # value by a query constraint.
@@ -194,7 +190,7 @@
             constraint = self.constraints[col]
             if len(constraint) == 1 and constraint[0] \
                     and not constraint[0][0] in ('!', '~', '^', '$'):
-                if col in cols:
+                if col in cols and col not in self.time_fields:
                     cols.remove(col)
             if col == 'status' and not 'closed' in constraint \
                     and 'resolution' in cols:
@@ -298,7 +294,7 @@
                     result['href'] = req.href.ticket(val)
                 elif val is None:
                     val = '--'
-                elif name in ('changetime', 'time'):
+                elif name in self.time_fields:
                     val = datetime.fromtimestamp(int(val or 0), utc)
                 elif field and field['type'] == 'checkbox':
                     try:
@@ -424,17 +420,41 @@
 
         def get_constraint_sql(name, value, mode, neg):
             if name not in custom_fields:
-                name = 't.' + name
+                col = 't.' + name
             else:
-                name = name + '.value'
+                col = name + '.value'
             value = value[len(mode) + neg:]
 
+            if name in self.time_fields:
+                if ';' in value:
+                    (start, end) = [each.strip() for each in 
+                                    value.split(';', 1)]
+                else:
+                    (start, end) = (value.strip(), '')
+                col_cast = db.cast(col, 'int')
+                if start and end:
+                    start = to_timestamp(parse_date(start, req.tz))
+                    end = to_timestamp(parse_date(end, req.tz))
+                    return ("%s(%s>=%%s AND %s<%%s)" % (neg and 'NOT ' or '',
+                                                        col_cast, col_cast),
+                            (start, end))
+                elif start:
+                    start = to_timestamp(parse_date(start, req.tz))
+                    return ("%s%s>=%%s" % (neg and 'NOT ' or '', col_cast),
+                            (start, ))
+                elif end:
+                    end = to_timestamp(parse_date(end, req.tz))
+                    return ("%s%s<%%s" % (neg and 'NOT ' or '', col_cast),
+                            (end, ))
+                else:
+                    return None
+                
             if mode == '':
-                return ("COALESCE(%s,'')%s=%%s" % (name, neg and '!' or ''),
-                        value)
+                return ("COALESCE(%s,'')%s=%%s" % (col, neg and '!' or ''),
+                        (value, ))
+
             if not value:
                 return None
-            db = self.env.get_db_cnx()
             value = db.like_escape(value)
             if mode == '~':
                 value = '%' + value + '%'
@@ -442,10 +462,11 @@
                 value = value + '%'
             elif mode == '$':
                 value = '%' + value
-            return ("COALESCE(%s,'') %s%s" % (name, neg and 'NOT ' or '',
+            return ("COALESCE(%s,'') %s%s" % (col, neg and 'NOT ' or '',
                                               db.like()),
-                    value)
+                    (value, ))
 
+        db = self.env.get_db_cnx()
         clauses = []
         args = []
         for k, v in self.constraints.items():
@@ -479,7 +500,7 @@
                     clauses.append('%s(%s)' % (neg and 'NOT ' or '',
                                                ' OR '.join(id_clauses)))
             # Special case for exact matches on multiple values
-            elif not mode and len(v) > 1:
+            elif not mode and len(v) > 1 and k not in self.time_fields:
                 if k not in custom_fields:
                     col = 't.' + k
                 else:
@@ -500,12 +521,13 @@
                 else:
                     clauses.append("(" + " OR ".join(
                         [item[0] for item in constraint_sql]) + ")")
-                args += [item[1] for item in constraint_sql]
+                for item in constraint_sql:
+                    args.extend(item[1])
             elif len(v) == 1:
                 constraint_sql = get_constraint_sql(k, v[0], mode, neg)
                 if constraint_sql:
                     clauses.append(constraint_sql[0])
-                    args.append(constraint_sql[1])
+                    args.extend(constraint_sql[1])
 
         clauses = filter(None, clauses)
         if clauses or cached_ids:
@@ -531,7 +553,7 @@
             # FIXME: This is a somewhat ugly hack.  Can we also have the
             #        column type for this?  If it's an integer, we do first
             #        one, if text, we do 'else'
-            if name in ('id', 'time', 'changetime'):
+            if name == 'id' or name in self.time_fields:
                 sql.append("COALESCE(%s,0)=0%s," % (col, desc))
             else:
                 sql.append("COALESCE(%s,'')=''%s," % (col, desc))
@@ -576,10 +598,6 @@
         cols = self.get_columns()
         labels = dict([(f['name'], f['label']) for f in self.fields])
 
-        # TODO: remove after adding time/changetime to the api.py
-        labels['changetime'] = _('Modified')
-        labels['time'] = _('Created')
-
         headers = [{
             'name': col, 'label': labels.get(col, _('Ticket')),
             'href': self.get_href(context.href, order=col,
@@ -607,6 +625,10 @@
         modes['select'] = [
             {'name': _("is"), 'value': ""},
             {'name': _("is not"), 'value': "!"}
+        ]
+        modes['time'] = [
+            {'name': _("is between"), 'value': ""},
+            {'name': _("is not between"), 'value': "!"},
         ]
 
         groups = {}
@@ -807,9 +829,14 @@
 
     def _get_constraints(self, req):
         constraints = {}
-        ticket_fields = [f['name'] for f in
-                         TicketSystem(self.env).get_ticket_fields()]
+        fields = TicketSystem(self.env).get_ticket_fields()
+        synonyms = TicketSystem(self.env).get_field_synonyms()
+        ticket_fields = [f['name'] for f in fields]
         ticket_fields.append('id')
+        ticket_fields.extend(synonyms.iterkeys())
+        time_fields = [f['name'] for f in fields if f['type'] == 'time']
+        time_fields.extend([k for (k, v) in synonyms.iteritems() 
+                            if v in time_fields])
 
         # For clients without JavaScript, we remove constraints here if
         # requested
@@ -831,6 +858,11 @@
                 mode = req.args.get(field + '_mode')
                 if mode:
                     vals = [mode + x for x in vals]
+                if field in time_fields:
+                    ends = req.args.getlist(field + '_end')
+                    if ends:
+                        vals = [start + ';' + end 
+                                for (start, end) in zip(vals, ends)]
                 if field in remove_constraints:
                     idx = remove_constraints[field]
                     if idx >= 0:
@@ -839,7 +871,8 @@
                             continue
                     else:
                         continue
-                constraints[field] = vals
+                constraints.setdefault(synonyms.get(field, field), 
+                                       []).extend(vals)
 
         return constraints
 
@@ -935,6 +968,8 @@
                     if col in ('cc', 'reporter'):
                         value = Chrome(self.env).format_emails(context(ticket),
                                                                value)
+                    elif col in query.time_fields:
+                        value = format_datetime(value, tzinfo=req.tz)
                     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.html b/trac/ticket/templates/query.html
--- a/trac/ticket/templates/query.html
+++ b/trac/ticket/templates/query.html
@@ -42,7 +42,7 @@
             <py:for each="field_name, field in fields.items()">
               <py:for each="constraint_name, constraint in constraints.items()">
                 <tbody py:if="field_name == constraint_name"
-                  py:with="multiline = field.type in ('select', 'text')">
+                  py:with="multiline = field.type in ('select', 'text', 'time')">
                   <py:for each="constraint_idx, constraint_value in enumerate(constraint['values'])">
                     <tr class="${field_name}" py:if="multiline or constraint_idx == 0">
                       <py:choose test="constraint_idx">
@@ -94,6 +94,14 @@
 
                         <py:when test="'text'">
                           <input type="text" name="${field_name}" value="$constraint_value" size="42" />
+                        </py:when>
+                        
+                        <py:when test="'time'" py:with="(start, end) = ';' in constraint_value 
+                                                        and constraint_value.split(';', 1)
+                                                        or (constraint_value, '')">
+                          <input type="text" name="${field_name}" value="$start" size="14" />
+                          <label>and</label>
+                          <input type="text" name="${field_name}_end" value="$end" size="14" />
                         </py:when>
 
                       </td>
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
@@ -125,8 +125,8 @@
         <div id="ticket" py:if="ticket.exists or preview_mode"
           class="${preview_mode and 'ticketdraft' or None}">
           <div class="date">
-            <p py:if="ticket.exists">Opened ${dateinfo(ticket.time_created)} ago</p>
-            <p py:if="ticket.time_changed != ticket.time_created">Last modified ${dateinfo(ticket.time_changed)} ago</p>
+            <p py:if="ticket.exists">Opened ${dateinfo(ticket.time)} ago</p>
+            <p py:if="ticket.changetime != ticket.time">Last modified ${dateinfo(ticket.changetime)} ago</p>
             <p py:if="not ticket.exists"><i>(ticket not yet created)</i></p>
           </div>
           <!-- use a placeholder if it's a new ticket -->
diff --git a/trac/ticket/tests/notification.py b/trac/ticket/tests/notification.py
--- a/trac/ticket/tests/notification.py
+++ b/trac/ticket/tests/notification.py
@@ -605,7 +605,8 @@
                 # note project title / URL are not validated yet
 
         # ticket properties which are not expected in the banner
-        xlist = ['summary', 'description', 'link', 'comment', 'new']
+        xlist = ['summary', 'description', 'link', 'comment', 'new',
+                 'time', 'changetime']
         # check banner content (field exists, msg value matches ticket value)
         for p in [prop for prop in ticket.values.keys() if prop not in xlist]:
             self.failIf(not props.has_key(p))
diff --git a/trac/ticket/tests/query.py b/trac/ticket/tests/query.py
--- a/trac/ticket/tests/query.py
+++ b/trac/ticket/tests/query.py
@@ -2,6 +2,7 @@
 from trac.mimeview import Context
 from trac.test import Mock, EnvironmentStub, MockPerm
 from trac.ticket.query import Query, QueryModule
+from trac.util.datefmt import utc
 from trac.web.href import Href
 from trac.wiki.formatter import LinkFormatter
 from trac.db.sqlite_backend import sqlite_version
@@ -35,7 +36,7 @@
 
     def setUp(self):
         self.env = EnvironmentStub(default_data=True)
-        self.req = Mock(href=self.env.href, authname='anonymous')
+        self.req = Mock(href=self.env.href, authname='anonymous', tz=utc)
         
 
     def test_all_ordered_by_id(self):
@@ -336,10 +337,71 @@
         self.assertEqual([], args)
         tickets = query.execute(self.req)
 
+    def test_constrained_by_time_range(self):
+        query = Query.from_string(self.env, 'created=2008-08-01;2008-09-01', order='id')
+        sql, args = query.get_sql(self.req)
+        self.assertEqualSQL(sql,
+"""SELECT t.id AS id,t.summary AS summary,t.time AS time,t.owner AS owner,t.type AS type,t.status AS status,t.priority AS priority,t.changetime AS changetime,priority.value AS priority_value
+FROM ticket AS t
+  LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority)
+WHERE (CAST(t.time AS int)>=%s AND CAST(t.time AS int)<%s)
+ORDER BY COALESCE(t.id,0)=0,t.id""")
+        self.assertEqual([1217548800, 1220227200], args)
+        tickets = query.execute(self.req)
+
+    def test_constrained_by_time_range_exclusion(self):
+        query = Query.from_string(self.env, 'created!=2008-08-01;2008-09-01', order='id')
+        sql, args = query.get_sql(self.req)
+        self.assertEqualSQL(sql,
+"""SELECT t.id AS id,t.summary AS summary,t.time AS time,t.owner AS owner,t.type AS type,t.status AS status,t.priority AS priority,t.changetime AS changetime,priority.value AS priority_value
+FROM ticket AS t
+  LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority)
+WHERE NOT (CAST(t.time AS int)>=%s AND CAST(t.time AS int)<%s)
+ORDER BY COALESCE(t.id,0)=0,t.id""")
+        self.assertEqual([1217548800, 1220227200], args)
+        tickets = query.execute(self.req)
+
+    def test_constrained_by_time_range_open_right(self):
+        query = Query.from_string(self.env, 'created=2008-08-01;', order='id')
+        sql, args = query.get_sql(self.req)
+        self.assertEqualSQL(sql,
+"""SELECT t.id AS id,t.summary AS summary,t.time AS time,t.owner AS owner,t.type AS type,t.status AS status,t.priority AS priority,t.changetime AS changetime,priority.value AS priority_value
+FROM ticket AS t
+  LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority)
+WHERE CAST(t.time AS int)>=%s
+ORDER BY COALESCE(t.id,0)=0,t.id""")
+        self.assertEqual([1217548800], args)
+        tickets = query.execute(self.req)
+
+    def test_constrained_by_time_range_open_left(self):
+        query = Query.from_string(self.env, 'created=;2008-09-01', order='id')
+        sql, args = query.get_sql(self.req)
+        self.assertEqualSQL(sql,
+"""SELECT t.id AS id,t.summary AS summary,t.time AS time,t.owner AS owner,t.type AS type,t.status AS status,t.priority AS priority,t.changetime AS changetime,priority.value AS priority_value
+FROM ticket AS t
+  LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority)
+WHERE CAST(t.time AS int)<%s
+ORDER BY COALESCE(t.id,0)=0,t.id""")
+        self.assertEqual([1220227200], args)
+        tickets = query.execute(self.req)
+
+    def test_constrained_by_time_range_modified(self):
+        query = Query.from_string(self.env, 'modified=2008-08-01;2008-09-01', order='id')
+        sql, args = query.get_sql(self.req)
+        self.assertEqualSQL(sql,
+"""SELECT t.id AS id,t.summary AS summary,t.changetime AS changetime,t.owner AS owner,t.type AS type,t.status AS status,t.priority AS priority,t.time AS time,priority.value AS priority_value
+FROM ticket AS t
+  LEFT OUTER JOIN enum AS priority ON (priority.type='priority' AND priority.name=priority)
+WHERE (CAST(t.changetime AS int)>=%s AND CAST(t.changetime AS int)<%s)
+ORDER BY COALESCE(t.id,0)=0,t.id""")
+        self.assertEqual([1217548800, 1220227200], args)
+        tickets = query.execute(self.req)
+
     def test_csv_escape(self):
         query = Mock(get_columns=lambda: ['col1'],
                      execute=lambda r,c: [{'id': 1,
-                                           'col1': 'value, needs escaped'}])
+                                           'col1': 'value, needs escaped'}],
+                     time_fields=['time', 'changetime'])
         content, mimetype = QueryModule(self.env).export_csv(
                                 Mock(href=self.env.href, perm=MockPerm()),
                                 query)
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
@@ -39,7 +39,7 @@
 from trac.timeline.api import ITimelineEventProvider
 from trac.util import get_reporter_id, partition
 from trac.util.compat import any
-from trac.util.datefmt import to_timestamp, utc
+from trac.util.datefmt import format_datetime, to_timestamp, utc
 from trac.util.text import CRLF, shorten_line, obfuscate_email_address
 from trac.util.presentation import separated
 from trac.util.translation import _, tag_, N_, gettext
@@ -520,7 +520,7 @@
                          'reassign_owner': req.authname,
                          'resolve_resolution': None,
                          # Store a timestamp for detecting "mid air collisions"
-                         'timestamp': str(ticket.time_changed)})
+                         'timestamp': str(ticket['changetime'])})
 
         self._insert_ticket_data(req, ticket, data,
                                  get_reporter_id(req, 'author'), field_changes)
@@ -653,7 +653,7 @@
         history = [c for c in history if any([f in text_fields
                                               for f in c['fields']])]
         history.append({'version': 0, 'comment': "''Initial version''",
-                        'date': ticket.time_created,
+                        'date': ticket['time'],
                         'author': ticket['reporter'] # not 100% accurate...
                         })
         data.update({'title': _('Ticket History'),
@@ -825,17 +825,21 @@
     def export_csv(self, req, ticket, sep=',', mimetype='text/plain'):
         # FIXME: consider dumping history of changes here as well
         #        as one row of output doesn't seem to be terribly useful...
+        fields = [f for f in ticket.fields 
+                  if f['name'] not in ('time', 'changetime')]
         content = StringIO()
         writer = csv.writer(content, delimiter=sep, quoting=csv.QUOTE_MINIMAL)
-        writer.writerow(['id'] + [unicode(f['name']) for f in ticket.fields])
+        writer.writerow(['id'] + [unicode(f['name']) for f in fields])
 
         context = Context.from_request(req, ticket.resource)
         cols = [unicode(ticket.id)]
-        for f in ticket.fields:
+        for f in fields:
             name = f['name']
             value = ticket.values.get(name, '')
             if name in ('cc', 'reporter'):
                 value = Chrome(self.env).format_emails(context, value, ' ')
+            elif name in ticket.time_fields:
+                value = format_datetime(value, tzinfo=req.tz)
             cols.append(value.encode('utf-8'))
         writer.writerow(cols)
         return (content.getvalue(), '%s;charset=utf-8' % mimetype)
@@ -901,7 +905,7 @@
 
         # Mid air collision?
         if ticket.exists and (ticket._old or comment):
-            if req.args.get('ts') != str(ticket.time_changed):
+            if req.args.get('ts') != str(ticket['changetime']):
                 add_warning(req, _("Sorry, can not save your changes. "
                               "This ticket has been modified by someone else "
                               "since you started"))
@@ -1085,7 +1089,7 @@
 
             # per field settings
             if name in ('summary', 'reporter', 'description', 'status',
-                        'resolution'):
+                        'resolution', 'time', 'changetime'):
                 field['skip'] = True
             elif name == 'owner':
                 field['skip'] = True
diff --git a/trac/util/datefmt.py b/trac/util/datefmt.py
--- a/trac/util/datefmt.py
+++ b/trac/util/datefmt.py
@@ -172,9 +172,7 @@
 
 def parse_date(text, tzinfo=None):
     tzinfo = tzinfo or localtz
-    if text == 'now': # TODO: today, yesterday, etc.
-        return datetime.now(utc)
-    tm = None
+    dt = None
     text = text.strip()
     # normalize ISO time
     match = _ISO_8601_RE.match(text)
@@ -198,22 +196,25 @@
             tm = time.strptime('%s ' * 6 % (years, months, days,
                                             hours, minutes, seconds),
                                '%Y %m %d %H %M %S ')
+            dt = datetime(*(tm[0:6] + (0, tzinfo)))
         except ValueError:
             pass
-    else:
+    if dt is None:
         for format in ['%x %X', '%x, %X', '%X %x', '%X, %x', '%x', '%c',
                        '%b %d, %Y']:
             try:
                 tm = time.strptime(text, format)
+                dt = datetime(*(tm[0:6] + (0, tzinfo)))
                 break
             except ValueError:
                 continue
-    if tm == None:
+    if dt is None:
+        dt = _parse_relative_time(text, tzinfo)
+    if dt is None:
         hint = get_date_format_hint()        
         raise TracError('"%s" is an invalid date, or the date format '
                         'is not known. Try "%s" instead.' % (text, hint),
                         'Invalid Date')
-    dt = datetime(*(tm[0:6] + (0, tzinfo)))
     # Make sure we can convert it to a timestamp and back - fromtimestamp()
     # may raise ValueError if larger than platform C localtime() or gmtime()
     try:
@@ -223,6 +224,68 @@
                         'Try a date closer to present time.' % (text,),
                         'Invalid Date')
     return dt
+
+
+_REL_TIME_RE = re.compile(
+    r'(\d+\.?\d*)\s*'
+    r'(second|minute|hour|day|week|month|year|[hdwmy])s?\s*'
+    r'(?:ago)?$')
+_time_intervals = dict(
+    second=lambda v: timedelta(seconds=v),
+    minute=lambda v: timedelta(minutes=v),
+    hour=lambda v: timedelta(hours=v),
+    day=lambda v: timedelta(days=v),
+    week=lambda v: timedelta(weeks=v),
+    month=lambda v: timedelta(days=30 * v),
+    year=lambda v: timedelta(days=365 * v),
+    h=lambda v: timedelta(hours=v),
+    d=lambda v: timedelta(days=v),
+    w=lambda v: timedelta(weeks=v),
+    m=lambda v: timedelta(days=30 * v),
+    y=lambda v: timedelta(days=365 * v),
+)
+_TIME_START_RE = re.compile(r'(this|last)\s*'
+                            r'(second|minute|hour|day|week|month|year)$')
+_time_starts = dict(
+    second=lambda now: now.replace(microsecond=0),
+    minute=lambda now: now.replace(microsecond=0, second=0),
+    hour=lambda now: now.replace(microsecond=0, second=0, minute=0),
+    day=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0),
+    week=lambda now: now.replace(microsecond=0, second=0, minute=0, hour=0) \
+                     - timedelta(days=now.weekday()),
+    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),
+)
+
+def _parse_relative_time(text, tzinfo):
+    now = datetime.now(tzinfo)
+    if text == 'now':
+        return now
+    if text == 'today':
+        return now.replace(microsecond=0, second=0, minute=0, hour=0)
+    if text == 'yesterday':
+        return now.replace(microsecond=0, second=0, minute=0, hour=0) \
+               - timedelta(days=1)
+    match = _REL_TIME_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()
+        dt = _time_starts[start](now)
+        if which == 'last':
+            if start == 'month':
+                if dt.month > 1:
+                    dt = dt.replace(month=dt.month - 1)
+                else:
+                    dt = dt.replace(year=dt.year - 1, month=12)
+            else:
+                dt -= _time_intervals[start](1)
+        return dt
+    return None
 
 
 # -- timezone utilities

