untitaker: @hobarrera I think I made all requested changes.
untitaker: re https://github.com/pimutils/todoman/pull/117/commits/ab7a9958892595d580503332af58b6f8b2ec8fa6: Yeah, that's probably cleaner.
hobarrera: > re ab7a995: Yeah, that's probably cleaner.

Great, I just though it was faster to push it than explain it. 😄 
untitaker: Ok i think this is ready to merge

untitaker: Since you don't appear to be on IRC:

>I would rather revert back to the old variant. Eventually I want to add support for bare dates for DUE (without time component) and this preprocessing makes it very hard.
hobarrera: Yeah, I jump in and out of IRC, TBH.

So, does any of the rest of the stack (rfc/icalendar/`Todo`/cache) support bare `date`s?
untitaker: No, it would all have to be refactored for that. My point is that this code will require more changes with your proposed implementation.
hobarrera: You're right, this won't work formatting separately, sadly. :(

I wonder if we might just as well using something like [`arrow.humanize`](http://crsmithdev.com/arrow/#humanize) ([code](https://github.com/crsmithdev/arrow/blob/a78bb2ffde8582d648d54ecc0a6b04d61408e4cb/arrow/arrow.py#L703)) to do the work for us.
hobarrera: I've also had my eye on [maya](https://github.com/kennethreitz/maya) for some time now, and it might also be capable of doing some parsing (though we  really _should_ review it before changing anything).
untitaker: Possibly in the future, but that's a lot of code there. I think we should be more conservative about "humanizing" datetimes, preferrably without loss of precision

hobarrera: Back to this PR though:
* Yes, my proposal is bad.
* We should also support things like `--due 10:30`. If the event has a date, only change the time. If it doesn't make it today at 10:30.
untitaker: I force-pushed the old variant.
untitaker: >If the event has a date, only change the time.

This will be complicated. Right now only datetimes are passed around.
hobarrera: Sure, let's keep this simple and discuss more improvements in a separate issue.
untitaker: I pushed a commit that allows the user to enter `--due 12:00`, and it will be parsed as "today 12am" (regardless of previous due value)
hobarrera: > If the event has a date, only change the time.

I meant, "if it has a `datetime`" (as opposed to having `None`).

untitaker: So to be clear, if I set `todo.due` to a `time`, you want that only the time component gets updated, not the entire value replaced? This sounds counterintuitive.
hobarrera: My idea was that, basically, when a user sets a time (note: this is really pseuocode, not python):

```python
if todo.due:
   todo.due.set_time(time):
else:
   todo.due = datetime(today, time)
```
Does this seem too unintuitive (I'm biased because it's my idea)?
untitaker: In the CLI module `todo` properties are set in a much more generic way.
hobarrera: Okay then, I won't distract any more, and let's aim to keep this simple. ^_^
untitaker: I added a bit of validation to the config parsing code, please review again.
hobarrera: I pushed the changes I was suggesting for the validation.
untitaker: Oops, sorry I didn't notice that there are already ConfigObj validators, or that ConfigObj even had that capability.
hobarrera: LGTM, but since **I** pushed the last change, I'll also wait for you thumbs-up.
untitaker: I woudl but I cannot approve my own RP
hobarrera: Feel free to merge 🚀 
untitaker: Travis triggers a `push`-type build, but that is nonsensical since the PR branch is outside of the main repo. Since this is a required status check I can't merge the PR
hobarrera: I squash-pushed this, travis should stop being dumb (I think it got confused when I pushed to `pimutils/time-support`, but that's still a bug on their side.
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 32fa29b..0740b25 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -7,6 +7,8 @@ releases, in reverse chronological order.
 v2.2.0
 ------
 
+* Basic support for times in due dates, new ``time_format`` configuration
+  parameter.
 * Use the system's date format as a default.
 * Show "Today" or "Tomorrow" when due date is today/tomorrow respectively.
 
diff --git a/todoman.conf.sample b/todoman.conf.sample
index 08fa0b0..3a84f32 100644
--- a/todoman.conf.sample
+++ b/todoman.conf.sample
@@ -2,5 +2,6 @@
 # A glob expression which matches all directories relevant.
 path = ~/.local/share/calendars/*
 date_format = %Y-%m-%d
+time_format = %H:%M
 default_list = Personal
 default_due = 48
diff --git a/todoman/cli.py b/todoman/cli.py
index 1e97645..811cc78 100644
--- a/todoman/cli.py
+++ b/todoman/cli.py
@@ -43,7 +43,7 @@ def _validate_list_param(ctx, param=None, name=None):
 
 def _validate_date_param(ctx, param, val):
     try:
-        return ctx.obj['formatter'].parse_date(val)
+        return ctx.obj['formatter'].parse_datetime(val)
     except ValueError as e:
         raise click.BadParameter(e)
 
@@ -97,7 +97,11 @@ def cli(ctx, color, porcelain):
     if porcelain:
         ctx.obj['formatter'] = PorcelainFormatter()
     else:
-        ctx.obj['formatter'] = TodoFormatter(config['main']['date_format'])
+        ctx.obj['formatter'] = TodoFormatter(
+            config['main']['date_format'],
+            config['main']['time_format'],
+            config['main']['dt_separator'],
+        )
 
     color = color or config['main']['color']
     if color == 'always':
diff --git a/todoman/configuration.py b/todoman/configuration.py
index aa394ff..667baff 100644
--- a/todoman/configuration.py
+++ b/todoman/configuration.py
@@ -33,6 +33,24 @@ def validate_cache_path(path):
         )
 
 
+def validate_date_format(fmt):
+    if any(x in fmt for x in ('%H', '%M', '%S', '%X')):
+        raise ConfigurationException(
+            'Found time component in `date_format`, please use `time_format` '
+            'for that.'
+        )
+    return fmt
+
+
+def validate_time_format(fmt):
+    if any(x in fmt for x in ('%Y', '%y', '%m', '%d', '%x')):
+        raise ConfigurationException(
+            'Found date component in `time_format`, please use `date_format` '
+            'for that.'
+        )
+    return fmt
+
+
 def validate_color(color):
     color = is_option(color)
     if color == 'always':
@@ -67,6 +85,8 @@ def load_config():
     validator = Validator({
         'expand_path': expand_path,
         'cache_path': validate_cache_path,
+        'date_format': validate_date_format,
+        'time_format': validate_time_format,
     })
 
     config = ConfigObj(path, configspec=specpath, file_error=True)
diff --git a/todoman/confspec.ini b/todoman/confspec.ini
index c5945f7..d900d7e 100644
--- a/todoman/confspec.ini
+++ b/todoman/confspec.ini
@@ -12,7 +12,13 @@ color = option('always', 'auto', 'never', default='auto')
 
 # The date format used both for displaying dates, and parsing input dates. If
 # this option is not specified the system locale's is used.
-date_format = string(default='%x')
+date_format = date_format(default='%x')
+
+# The date format used both for displaying times, and parsing input times.
+time_format = time_format(default='%X')
+
+# The string used to separate date and time when displaying and parsing.
+dt_separator = string(default=' ')
 
 # The default list for adding a todo. If you do not specify this option, you
 # must use the ``--list`` / ``-l`` option every time you add a todo.
diff --git a/todoman/ui.py b/todoman/ui.py
index b9b6efa..82baeb3 100644
--- a/todoman/ui.py
+++ b/todoman/ui.py
@@ -1,5 +1,5 @@
+import datetime
 import json
-from datetime import datetime, timedelta
 from time import mktime
 
 import click
@@ -39,13 +39,13 @@ class TodoEditor:
 
         if todo.due:
             # TODO: use proper date_format
-            due = formatter.format_date(todo.due)
+            due = formatter.format_datetime(todo.due)
         else:
             due = ""
 
         if todo.start:
             # TODO: use proper date_format
-            dtstart = formatter.format_date(todo.start)
+            dtstart = formatter.format_datetime(todo.start)
         else:
             dtstart = ''
 
@@ -143,8 +143,8 @@ class TodoEditor:
         self.todo.summary = self.summary
         self.todo.description = self.description
         self.todo.location = self.location
-        self.todo.due = self.formatter.parse_date(self.due)
-        self.todo.start = self.formatter.parse_date(self.dtstart)
+        self.todo.due = self.formatter.parse_datetime(self.due)
+        self.todo.start = self.formatter.parse_datetime(self.dtstart)
 
         self.todo.is_completed = self._completed.get_state()
 
@@ -200,15 +200,22 @@ class TodoFormatter:
         "{id:3d} [{completed}] {urgent} {due} {summary} {list}{percent}"
     # compact_format = "{completed} {urgent}  {due}  {summary}"
 
-    def __init__(self, date_format):
+    def __init__(self, date_format, time_format, dt_separator):
         self.date_format = date_format
+        self.time_format = time_format
+        self.dt_separator = dt_separator
+        self.datetime_format = date_format + dt_separator + time_format
+
         self._localtimezone = tzlocal()
-        self.now = datetime.now().replace(tzinfo=self._localtimezone)
-        self.tomorrow = self.now.date() + timedelta(days=1)
+        self.now = datetime.datetime.now().replace(tzinfo=self._localtimezone)
+        self.tomorrow = self.now.date() + datetime.timedelta(days=1)
 
         # An empty date which should be used in case no date is present
         self.date_width = len(self.now.strftime(date_format))
         self.empty_date = " " * self.date_width
+
+        self.time_width = len(self.now.strftime(time_format))
+        self.empty_time = " " * self.time_width
         # Map special dates to the special string we need to return
         self.special_dates = {
             self.now.date(): "Today".rjust(self.date_width, " "),
@@ -230,7 +237,7 @@ class TodoFormatter:
             percent = " ({}%)".format(percent)
         urgent = " " if todo.priority in [None, 0] else "!"
 
-        due = self.format_date(todo.due)
+        due = self.format_datetime(todo.due)
         if todo.due and todo.due <= self.now and not todo.is_completed:
             due = click.style(due, fg='red')
 
@@ -258,40 +265,76 @@ class TodoFormatter:
             rv = "{}\n\n{}".format(rv, todo.description)
         return rv
 
-    def format_date(self, date):
+    def _format_date(self, date):
         """
         Returns date in the following format:
         * if date == today or tomorrow: "Today" or "Tomorrow"
         * else: return a string representing that date
         * if no date is supplied, it returns empty_date
 
-        :param datetime.datetime date: a datetime object
+        :param datetime.date date: a date object
         """
         if date:
-            assert isinstance(date, datetime)
-            if date.date() in self.special_dates:
-                rv = self.special_dates[date.date()]
+            if date in self.special_dates:
+                rv = self.special_dates[date]
             else:
                 rv = date.strftime(self.date_format)
             return rv
         else:
             return self.empty_date
 
-    def parse_date(self, date):
-        if not date:
+    def _format_time(self, time):
+        if time:
+            return time.strftime(self.time_format)
+        else:
+            return self.empty_time
+
+    def format_datetime(self, dt):
+        if not dt:
+            date_part = None
+            time_part = None
+        else:
+            assert isinstance(dt, datetime.datetime)
+            date_part = dt.date()
+            time_part = dt.time()
+
+        return self.dt_separator.join(filter(bool, (
+            self._format_date(date_part),
+            self._format_time(time_part)
+        )))
+
+    def parse_datetime(self, dt):
+        if not dt:
             return None
 
+        rv = self._parse_datetime_naive(dt)
+        return rv.replace(tzinfo=self._localtimezone)
+
+    def _parse_datetime_naive(self, dt):
         try:
-            rv = datetime.strptime(date, self.date_format)
+            return datetime.datetime.strptime(dt, self.datetime_format)
         except ValueError:
-            rv, certainty = self._parsedatetime_calendar.parse(date)
-            if not certainty:
-                raise ValueError(
-                    'Time description not recognized: {}' .format(date)
-                )
-            rv = datetime.fromtimestamp(mktime(rv))
+            pass
 
-        return rv.replace(tzinfo=self._localtimezone)
+        try:
+            return datetime.datetime.strptime(dt, self.date_format)
+        except ValueError:
+            pass
+
+        try:
+            return datetime.datetime.combine(
+                self.now.date(),
+                datetime.datetime.strptime(dt, self.time_format).time()
+            )
+        except ValueError:
+            pass
+
+        rv, certainty = self._parsedatetime_calendar.parse(dt)
+        if not certainty:
+            raise ValueError(
+                'Time description not recognized: {}' .format(dt)
+            )
+        return datetime.datetime.fromtimestamp(mktime(rv))
 
     def format_database(self, database):
         return '{}@{}'.format(database.color_ansi or '',
@@ -303,7 +346,7 @@ class PorcelainFormatter:
     def compact(self, todo):
         data = dict(
             completed=todo.is_completed,
-            due=self.format_date(todo.due),
+            due=self.format_datetime(todo.due),
             id=todo.id,
             list=todo.list.name,
             percent=todo.percent_complete,
@@ -316,7 +359,7 @@ class PorcelainFormatter:
 
     detailed = compact
 
-    def format_date(self, date):
+    def format_datetime(self, date):
         if date:
             return int(date.timestamp())
         else:

Support times for due dates
- Todoman should introduce a `time_format` configuration parameter that, if non-empty, is *additionally* used to format due datetimes. It should be omitted if `DUE` does not have a time component.
- Todoman should prevent people from using `%H`, %M` and so on in `date_format` (I'm currently doing that)
- Likewise it should prevent people from using `%Y` etc in `time_format`
diff --git a/tests/conftest.py b/tests/conftest.py
index 9ecafaf..0d66165 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -22,6 +22,7 @@ def config(tmpdir, default_database):
     path.write('[main]\n'
                'path = {}/*\n'
                'date_format = %Y-%m-%d\n'
+               'time_format = \n'
                'cache_path = {}\n'
                .format(str(tmpdir), str(tmpdir.join('cache.sqlite3'))))
     return path
diff --git a/tests/test_formatter.py b/tests/test_formatter.py
index cb5b9d3..67b6de1 100644
--- a/tests/test_formatter.py
+++ b/tests/test_formatter.py
@@ -4,18 +4,23 @@ from random import randrange
 from todoman import ui
 
 DATE_FORMAT = "%d-%m-%y"
+TIME_FORMAT = "%H:%M"
 
 
-def test_format_date():
-    """
-    Tests the format_date function in todoman.ui.TodoFormatter
-    """
-    formatter = ui.TodoFormatter(DATE_FORMAT)
+def test_human_dates():
+    formatter = ui.TodoFormatter(DATE_FORMAT, TIME_FORMAT, ' ')
     today = datetime.now()
     tomorrow = today + timedelta(days=1)
+    tomorrow_12am = datetime(tomorrow.year, tomorrow.month, tomorrow.day,
+                             12, 0)
+    today_12am = datetime(today.year, today.month, today.day,
+                          12, 0)
     any_day = today + timedelta(days=randrange(2, 8))
 
-    assert formatter.format_date("") == "        "
-    assert formatter.format_date(today) == "   Today"
-    assert formatter.format_date(tomorrow) == "Tomorrow"
-    assert formatter.format_date(any_day) == any_day.strftime(DATE_FORMAT)
+    assert formatter.format_datetime("") == "              "
+    assert formatter._format_date(today.date()) == "   Today"
+    assert formatter._format_date(tomorrow.date()) == "Tomorrow"
+    assert formatter.format_datetime(any_day) == \
+        any_day.strftime(DATE_FORMAT + ' ' + TIME_FORMAT)
+    assert formatter.format_datetime(tomorrow_12am) == "Tomorrow 12:00"
+    assert formatter.parse_datetime('12:00').replace(tzinfo=None) == today_12am

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-cov hypothesis
apt-get update apt-get install -y gcc
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
atomicwrites==1.4.1
attrs==25.3.0
click==8.1.8
click-log==0.4.0
configobj==5.0.9
coverage==7.8.0
exceptiongroup==1.2.2
hypothesis==6.130.5
icalendar==6.1.3
iniconfig==2.1.0
packaging==24.2
parsedatetime==2.6
pluggy==1.5.0
pytest==8.3.5
pytest-cov==6.0.0
python-dateutil==2.9.0.post0
pyxdg==0.28
six==1.17.0
sortedcontainers==2.4.0
-e git+https://github.com/pimutils/todoman.git@6a213db74ccb922e3a1c8e47a21a55746912feb6#egg=todoman
tomli==2.2.1
typing_extensions==4.13.0
tzdata==2025.2
urwid==2.6.16
wcwidth==0.2.13

name: todoman
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - atomicwrites==1.4.1
      - attrs==25.3.0
      - click==8.1.8
      - click-log==0.4.0
      - configobj==5.0.9
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - hypothesis==6.130.5
      - icalendar==6.1.3
      - iniconfig==2.1.0
      - packaging==24.2
      - parsedatetime==2.6
      - pluggy==1.5.0
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - python-dateutil==2.9.0.post0
      - pyxdg==0.28
      - six==1.17.0
      - sortedcontainers==2.4.0
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - tzdata==2025.2
      - urwid==2.6.16
      - wcwidth==0.2.13
prefix: /opt/conda/envs/todoman

tests/test_formatter.py::test_human_dates
todoman.conf.sample todoman/ui.py todoman/configuration.py CHANGELOG.rst todoman/cli.py todoman/confspec.ini
todoman.conf.sample todoman/ui.py todoman/configuration.py CHANGELOG.rst todoman/cli.py todoman/confspec.ini