Repository: sopel-irc/sopel

Issue:
[remind] .at does not accept a future date, limiting reminders to next 24 hours
I do not see any past (closed) issue about this, but the possible use cases beg the question: Why doesn't `.at` take a date in addition to a time?

It would allow scheduling reminders at a specific time more than 24 hours in the future, without first manually calculating the time difference and feeding it to `.in` instead.


Discussion:
I actually wondered that myself, and hacked together a fix that addresses this issue. The referenced PR fixes this issue, though I have not tested it on machines where pytz is not installed. (I was originally only writing the code for personal use, and didn't think to include support for that)
Pushed to 7.0.0, because #1162 was pushed there.
I'll work on that, reusing code from @alanhuang122 

Resolving patch:
diff --git a/sopel/modules/remind.py b/sopel/modules/remind.py
--- a/sopel/modules/remind.py
+++ b/sopel/modules/remind.py
@@ -18,7 +18,7 @@
 import pytz
 
 from sopel import tools, module
-from sopel.tools.time import get_timezone, format_time
+from sopel.tools.time import get_timezone, format_time, validate_timezone
 
 
 def get_filename(bot):
@@ -96,8 +96,19 @@ def dump_database(filename, data):
                 database.write(line)
 
 
-def create_reminder(bot, trigger, duration, message, timezone):
-    """Create a reminder into the ``bot``'s database and reply to the sender"""
+def create_reminder(bot, trigger, duration, message):
+    """Create a reminder into the ``bot``'s database and reply to the sender
+
+    :param bot: the bot's instance
+    :type bot: :class:`~sopel.bot.SopelWrapper`
+    :param trigger: the object that triggered the call
+    :type trigger: :class:`~sopel.trigger.Trigger`
+    :param int duration: duration from now, in seconds, until ``message``
+                         must be reminded
+    :param str message: message to be reminded
+    :return: the reminder's timestamp
+    :rtype: :class:`int`
+    """
     timestamp = int(time.time()) + duration
     reminder = (trigger.sender, trigger.nick, message)
     try:
@@ -106,18 +117,7 @@ def create_reminder(bot, trigger, duration, message, timezone):
         bot.rdb[timestamp] = [reminder]
 
     dump_database(bot.rfn, bot.rdb)
-
-    if duration >= 60:
-        human_time = format_time(
-            bot.db,
-            bot.config,
-            timezone,
-            trigger.nick,
-            trigger.sender,
-            datetime.utcfromtimestamp(timestamp))
-        bot.reply('Okay, will remind at %s' % human_time)
-    else:
-        bot.reply('Okay, will remind in %s secs' % duration)
+    return timestamp
 
 
 def setup(bot):
@@ -226,20 +226,197 @@ def remind_in(bot, trigger):
         duration = int(duration)
     timezone = get_timezone(
         bot.db, bot.config, None, trigger.nick, trigger.sender)
-    create_reminder(bot, trigger, duration, reminder, timezone)
+    timestamp = create_reminder(bot, trigger, duration, reminder)
+
+    if duration >= 60:
+        human_time = format_time(
+            bot.db,
+            bot.config,
+            timezone,
+            trigger.nick,
+            trigger.sender,
+            datetime.utcfromtimestamp(timestamp))
+        bot.reply('Okay, will remind at %s' % human_time)
+    else:
+        bot.reply('Okay, will remind in %s secs' % duration)
+
+
+REGEX_AT = re.compile(
+    # hours:minutes
+    r'(?P<hours>\d+):(?P<minutes>\d+)'
+    # optional seconds
+    r'(?::(?P<seconds>\d+))?'
+    # optional timezone
+    r'(?P<tz>[^\s\d]+)?'
+    # optional date (start)
+    r'(?:\s+'
+    # - date 1 (at least one digit)
+    r'(?P<date1>\d{1,4})'
+    # - separator (one character)
+    r'(?P<sep>[./-])'
+    # - date 2 (at least one digit)
+    r'(?P<date2>\d{1,4})'
+    # - optional sep + date 3 (at least one digit)
+    r'(?:(?P=sep)(?P<date3>\d{1,4}))?'
+    r')?'  # (end)
+    # at least one space + message
+    r'\s+(?P<message>.*)'
+)
+
+
+class TimeReminder(object):
+    """Time reminder for the ``at`` command"""
+    def __init__(self,
+                 hour,
+                 minute,
+                 second,
+                 timezone,
+                 date1,
+                 date2,
+                 date3,
+                 message):
+        self.hour = hour
+        self.minute = minute
+        self.second = second
+        self.timezone = pytz.timezone(timezone)
+        self.message = message
+
+        year = None
+        month = None
+        day = None
+
+        if date1 and date2 and date3:
+            if len(date1) == 4:
+                # YYYY-mm-dd
+                year = int(date1)
+                month = int(date2)
+                day = int(date3)
+            else:
+                # dd-mm-YYYY or dd/mm/YY
+                year = int(date3)
+                month = int(date2)
+                day = int(date1)
+        elif date1 and date2:
+            if len(date1) == 4:
+                # YYYY-mm
+                year = int(date1)
+                month = int(date2)
+            elif len(date2) == 4:
+                # mm-YYYY
+                year = int(date2)
+                month = int(date1)
+            else:
+                # dd/mm
+                month = int(date2)
+                day = int(date1)
+
+        self.year = year
+        self.month = month
+        self.day = day
+
+    def __eq__(self, other):
+        return all(
+            getattr(self, attr) == getattr(other, attr, None)
+            for attr in [
+                'hour',
+                'minute',
+                'second',
+                'timezone',
+                'year',
+                'month',
+                'day',
+                'message',
+            ]
+        )
+
+    def get_duration(self, today=None):
+        """Get the duration between the reminder and ``today``
+
+        :param today: aware datetime to compare to; defaults to current time
+        :type today: aware :class:`datetime.datetime`
+        :return: The duration, in second, between ``today`` and the reminder.
+        :rtype: :class:`int`
+
+        This method returns the number of seconds given by the
+        :class:`datetime.timedelta` obtained by the difference between the
+        reminder and ``today``.
+
+        If the delta between the reminder and ``today`` is negative, Python
+        will represent it as a negative number of days, and a positive number
+        of seconds: since it returns the number of seconds, any past reminder
+        will be transformed into a future reminder the next day.
+
+        .. seealso::
+
+            The :mod:`datetime` built-in module's documentation explains what
+            is an "aware" datetime.
+
+        """
+        if not today:
+            today = datetime.now(self.timezone)
+        else:
+            today = today.astimezone(self.timezone)
+
+        year = self.year if self.year is not None else today.year
+        month = self.month if self.month is not None else today.month
+        day = self.day if self.day is not None else today.day
 
+        at_time = datetime(
+            year, month, day,
+            self.hour, self.minute, self.second,
+            tzinfo=today.tzinfo)
 
-REGEX_AT = re.compile(r'(\d+):(\d+)(?::(\d+))?([^\s\d]+)? (.*)')
+        timediff = at_time - today
+        duration = timediff.seconds
+
+        if timediff.days > 0:
+            duration = duration + (86400 * timediff.days)
+
+        return duration
+
+
+def parse_regex_match(match, default_timezone=None):
+    """Parse a time reminder from ``match``
+
+    :param match: :obj:`~.REGEX_AT`'s matching result
+    :param default_timezone: timezone used when ``match`` doesn't have one;
+                             defaults to ``UTC``
+    :rtype: :class:`TimeReminder`
+    """
+    try:
+        timezone = validate_timezone(match.group('tz') or 'UTC')
+    except ValueError:
+        timezone = default_timezone or 'UTC'
+
+    return TimeReminder(
+        int(match.group('hours')),
+        int(match.group('minutes')),
+        int(match.group('seconds') or '0'),
+        timezone,
+        match.group('date1'),
+        match.group('date2'),
+        match.group('date3'),
+        match.group('message')
+    )
 
 
 @module.commands('at')
-@module.example('.at 13:47 Do your homework!')
+@module.example('.at 13:47 Do your homework!', user_help=True)
+@module.example('.at 03:14:07 2038-01-19 End of signed 32-bit int timestamp',
+                user_help=True)
+@module.example('.at 00:01 25/12 Open your gift!', user_help=True)
 def remind_at(bot, trigger):
-    """
-    Gives you a reminder at the given time. Takes `hh:mm:ssTimezone message`.
+    """Gives you a reminder at the given time.
+
+    Takes ``hh:mm:ssTimezone Date message`` where seconds, Timezone, and Date
+    are optional.
+
     Timezone is any timezone Sopel takes elsewhere; the best choices are those
     from the tzdb; a list of valid options is available at
-    <https://sopel.chat/tz>. The seconds and timezone are optional.
+    <https://sopel.chat/tz>.
+
+    The Date can be expressed in one of these formats: YYYY-mm-dd, dd-mm-YYYY,
+    dd-mm-YY, or dd-mm. The separator can be ``.``, ``-``, or ``/``.
     """
     if not trigger.group(2):
         bot.say("No arguments given for reminder command.")
@@ -247,26 +424,35 @@ def remind_at(bot, trigger):
     if trigger.group(3) and not trigger.group(4):
         bot.say("No message given for reminder.")
         return module.NOLIMIT
+
     match = REGEX_AT.match(trigger.group(2))
     if not match:
         bot.reply("Sorry, but I didn't understand your input.")
         return module.NOLIMIT
-    hour, minute, second, timezone, message = match.groups()
-    if not second:
-        second = '0'
-
-    timezone = get_timezone(bot.db, bot.config, timezone,
-                            trigger.nick, trigger.sender)
-    if not timezone:
-        timezone = 'UTC'
-
-    now = datetime.now(pytz.timezone(timezone))
-    at_time = datetime(now.year, now.month, now.day,
-                       int(hour), int(minute), int(second),
-                       tzinfo=now.tzinfo)
-    timediff = at_time - now
-    duration = timediff.seconds
-
-    if duration < 0:
-        duration += 86400
-    create_reminder(bot, trigger, duration, message, timezone)
+
+    default_timezone = get_timezone(bot.db, bot.config, None,
+                                    trigger.nick, trigger.sender)
+
+    reminder = parse_regex_match(match, default_timezone)
+
+    try:
+        duration = reminder.get_duration()
+    except ValueError as error:
+        bot.reply(
+            "Sorry, but I didn't understand your input: %s" % str(error))
+        return module.NOLIMIT
+
+    # save reminder
+    timestamp = create_reminder(bot, trigger, duration, reminder.message)
+
+    if duration >= 60:
+        human_time = format_time(
+            bot.db,
+            bot.config,
+            reminder.timezone.zone,
+            trigger.nick,
+            trigger.sender,
+            datetime.utcfromtimestamp(timestamp))
+        bot.reply('Okay, will remind at %s' % human_time)
+    else:
+        bot.reply('Okay, will remind in %s secs' % duration)


Test patch:
diff --git a/test/modules/test_modules_remind.py b/test/modules/test_modules_remind.py
--- a/test/modules/test_modules_remind.py
+++ b/test/modules/test_modules_remind.py
@@ -2,10 +2,11 @@
 """Tests for Sopel's ``remind`` plugin"""
 from __future__ import unicode_literals, absolute_import, print_function, division
 
-from collections import namedtuple
+from datetime import datetime
 import os
 
 import pytest
+import pytz
 
 from sopel import test_tools
 from sopel.modules import remind
@@ -19,58 +20,139 @@ def sopel():
     return bot
 
 
-TimeReminder = namedtuple(
-    'TimeReminder', ['hour', 'minute', 'second', 'tz', 'message'])
+WEIRD_MESSAGE = (
+    '( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ'
+    '( •ᴗ ^B^]^_ITS CARDBACK TIME!!!!!!!!!!!!!!!!!!!!!!!^B^]^_'
+    '( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ'
+)
 
 VALID_MATCH_LINES = (
-    ('1:20 message', TimeReminder('1', '20', None, None, 'message')),
-    ('01:20 message', TimeReminder('01', '20', None, None, 'message')),
-    ('13:37 message', TimeReminder('13', '37', None, None, 'message')),
-    ('13:37:00 message', TimeReminder('13', '37', '00', None, 'message')),
+    ('1:20 message',
+     remind.TimeReminder(1, 20, 0, 'UTC', None, None, None, 'message')),
+    ('01:20 message',
+     remind.TimeReminder(1, 20, 0, 'UTC', None, None, None, 'message')),
+    ('13:37 message',
+     remind.TimeReminder(13, 37, 0, 'UTC', None, None, None, 'message')),
+    ('13:37:00 message',
+     remind.TimeReminder(13, 37, 0, 'UTC', None, None, None, 'message')),
     # Make sure numbers are not confused with anything else
+    ('13:37:00 10 message',
+     remind.TimeReminder(13, 37, 0, 'UTC', None, None, None, '10 message')),
+    # Date dd-mm-YYYY (with different separators)
+    ('13:37:00 03-05-2019 message',
+     remind.TimeReminder(13, 37, 0, 'UTC', '03', '05', '2019', 'message')),
+    ('13:37:00 03/05/2019 message',
+     remind.TimeReminder(13, 37, 0, 'UTC', '03', '05', '2019', 'message')),
+    ('13:37:00 03.05.2019 message',
+     remind.TimeReminder(13, 37, 0, 'UTC', '03', '05', '2019', 'message')),
+    # If separator is wrong, then it won't be parsed as a date
     (
-        '13:37:00 10 message',
-        TimeReminder('13', '37', '00', None, '10 message')
+        '13:37:00 03,05,2019 message',
+        remind.TimeReminder(
+            13, 37, 0, 'UTC', None, None, None, '03,05,2019 message'
+        ),
     ),
-    # Weird stuff
     (
-        '13:37:00 message\tanother one',
-        TimeReminder('13', '37', '00', None, 'message\tanother one')
+        '13:37:00 03/05-2019 message',
+        remind.TimeReminder(
+            13, 37, 0, 'UTC', None, None, None, '03/05-2019 message'
+        ),
     ),
+    # Weird stuff
     (
-        '13:37:00 ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ'
-        '( •ᴗ ^B^]^_ITS CARDBACK TIME!!!!!!!!!!!!!!!!!!!!!!!^B^]^_'
-        '( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ',
-        TimeReminder(
-            '13', '37', '00', None,
-            '( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ'
-            '( •ᴗ ^B^]^_ITS CARDBACK TIME!!!!!!!!!!!!!!!!!!!!!!!^B^]^_'
-            '( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ( •ᴗ•)ψ'
-        )
+        '13:37:00 message\tanother one',
+        remind.TimeReminder(
+            13, 37, 0, 'UTC', None, None, None, 'message\tanother one'
+        ),
     ),
+    ('13:37:00 %s' % WEIRD_MESSAGE,
+     remind.TimeReminder(13, 37, 0, 'UTC', None, None, None, WEIRD_MESSAGE)),
     # Timezone
     (
         '13:37Europe/Paris message',
-        TimeReminder('13', '37', None, 'Europe/Paris', 'message')
+        remind.TimeReminder(
+            13, 37, 0, 'Europe/Paris', None, None, None, 'message'
+        ),
     ),
     (
         '13:37:00Europe/Paris message',
-        TimeReminder('13', '37', '00', 'Europe/Paris', 'message')
+        remind.TimeReminder(
+            13, 37, 0, 'Europe/Paris', None, None, None, 'message'
+        ),
     ),
-    # these should not pass, but they do at the moment
-    ('13:7 message', TimeReminder('13', '7', None, None, 'message')),
-    ('0000:20 message', TimeReminder('0000', '20', None, None, 'message')),
-    ('13:37:71 message', TimeReminder('13', '37', '71', None, 'message')),
+    # These should not pass but we are very tolerant a the moment
+    ('1:7 message',
+     remind.TimeReminder(1, 7, 0, 'UTC', None, None, None, 'message')),
+    ('13:7 message',
+     remind.TimeReminder(13, 7, 0, 'UTC', None, None, None, 'message')),
+    ('0000:20 message',
+     remind.TimeReminder(0, 20, 0, 'UTC', None, None, None, 'message')),
+    # These are weird but okay-ish at the moment
+    # Since the "date" part is optional, the regex thinks it's the message
+    ('13:37 2019-10-09',
+     remind.TimeReminder(13, 37, 0, 'UTC', None, None, None, '2019-10-09')),
+    ('13:37Europe/Paris 2019-10-09',
+     remind.TimeReminder(
+         13, 37, 0, 'Europe/Paris', None, None, None, '2019-10-09')),
+    ('13:37:00Europe/Paris 2019-10-09',
+     remind.TimeReminder(
+         13, 37, 0, 'Europe/Paris', None, None, None, '2019-10-09')),
 )
 
 
 @pytest.mark.parametrize('line, expected', VALID_MATCH_LINES)
-def test_at_regex_matches(line, expected):
+def test_at_parse_regex_match(line, expected):
     result = remind.REGEX_AT.match(line)
     assert result
 
-    reminder = TimeReminder(*result.groups())
+    reminder = remind.parse_regex_match(result, 'UTC')
     assert reminder == expected
+    assert reminder.get_duration() >= 0
+
+
+VALID_MATCH_INVALID_DATE_LINES = (
+    # invalid seconds
+    ('13:37:71 message',
+     remind.TimeReminder(13, 37, 71, 'UTC', None, None, None, 'message')),
+    ('13:37:60 message',
+     remind.TimeReminder(13, 37, 60, 'UTC', None, None, None, 'message')),
+    # invalid minutes
+    ('13:71:59 message',
+     remind.TimeReminder(13, 71, 59, 'UTC', None, None, None, 'message')),
+    ('13:60:59 message',
+     remind.TimeReminder(13, 60, 59, 'UTC', None, None, None, 'message')),
+    # invalid hours
+    ('24:30:15 message',
+     remind.TimeReminder(24, 30, 15, 'UTC', None, None, None, 'message')),
+    ('71:30:15 message',
+     remind.TimeReminder(71, 30, 15, 'UTC', None, None, None, 'message')),
+    # invalid date
+    ('13:37:25 2019-12-32 message',
+     remind.TimeReminder(13, 37, 25, 'UTC', '2019', '12', '32', 'message')),
+    ('13:37:25 2019-11-31 message',
+     remind.TimeReminder(13, 37, 25, 'UTC', '2019', '11', '31', 'message')),
+    ('13:37:25 2019-13-01 message',
+     remind.TimeReminder(13, 37, 25, 'UTC', '2019', '13', '01', 'message')),
+    # invalid date: using 00 or 0000
+    ('13:37:25 0000-12-32 message',
+     remind.TimeReminder(13, 37, 25, 'UTC', '0000', '12', '32', 'message')),
+    ('13:37:25 2019-00-32 message',
+     remind.TimeReminder(13, 37, 25, 'UTC', '2019', '00', '32', 'message')),
+    ('13:37:25 2019-12-00 message',
+     remind.TimeReminder(13, 37, 25, 'UTC', '2019', '12', '00', 'message')),
+)
+
+
+@pytest.mark.parametrize('line, expected', VALID_MATCH_INVALID_DATE_LINES)
+def test_at_parse_regex_match_invalid_duration(line, expected):
+    result = remind.REGEX_AT.match(line)
+    assert result
+
+    reminder = remind.parse_regex_match(result, 'UTC')
+    assert reminder == expected
+
+    with pytest.raises(ValueError):
+        reminder.get_duration()
 
 
 INVALID_MATCH_LINES = (
@@ -95,6 +177,141 @@ def test_at_regex_dont_match(line):
         'Result found for invalid line "%s": %s' % (line, result.groups()))
 
 
+SECONDS = 1
+ONE_MINUTE = MINUTES = 60 * SECONDS
+TWO_MINUTES = 2 * MINUTES
+HOURS = 60 * MINUTES
+ONE_DAY = DAYS = 24 * HOURS
+
+
+TEST_DATE_123 = (
+    # reminder is the same day
+    (None, None, None, TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    # reminder is the same day, with YYYY-mm-dd
+    ('2019', '05', '04', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('2019', '5', '4', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    # reminder is the same day, with dd-mm-YYYY or dd-mm-YY
+    ('04', '05', '2019', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('4', '5', '2019', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('04', '05', '19', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('4', '5', '19', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    # reminder is the same day, with YYYY-mm or mm-YYYY
+    ('2019', '05', None, TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('2019', '5', None, TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('05', '2019', None, TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('5', '2019', None, TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    # reminder is the same day, with dd/mm (or dd-mm for the fool)
+    ('04', '05', None, TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('04', '5', None, TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('4', '05', None, TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('4', '5', None, TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    # reminder is yesterday, with YYYY-mm-dd
+    ('2019', '05', '03', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('2019', '5', '3', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    # reminder is yesterday, with dd-mm-YYYY or dd-mm-YY
+    ('03', '05', '2019', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('3', '5', '2019', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('03', '05', '19', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('3', '5', '19', TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    # reminder is tomorrow
+    ('2019', '05', '05',
+     # compared to 2019-05-04 at 13:35:00,
+     # may 5th at 13:37:00 is tomorrow plus 2 minute
+     ONE_DAY + TWO_MINUTES,
+     # compared to 2019-05-04 at 13:38:00,
+     # may 5th at 13:37:00 is tomorrow, 1 minute sooner than today
+     ONE_DAY - ONE_MINUTE),
+    ('2019', '5', '5', ONE_DAY + TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('05', '05', '2019', ONE_DAY + TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+    ('5', '5', '2019', ONE_DAY + TWO_MINUTES, ONE_DAY - ONE_MINUTE),
+)
+
+
+@pytest.mark.parametrize('tz_name', ['UTC', 'Europe/Paris', 'America/Chicago'])
+@pytest.mark.parametrize('date1, date2, date3, expected1, expected2', TEST_DATE_123)
+def test_timereminder_get_duration(tz_name,
+                                   date1, date2, date3,
+                                   expected1,
+                                   expected2):
+    """Assert result of ``get_duration`` from reminder
+
+    :param str tz_name: the timezone used to generate aware today & reminder
+    :param str date1: numeric value given for date1 of reminder
+    :param str date2: numeric value given for date2 of reminder
+    :param str date3: numeric value given for date3 of reminder
+    :param expected1: expected duration, in seconds, for reminder compared to
+                      ``2019-05-04 13:35:00`` (same timezone)
+    :param expected2: duration, in seconds, for reminder compared to
+                      ``2019-05-04 13:38:00`` (same timezone)
+
+    The reminder will be created at a fixed time point of ``13:37:00`` local
+    to the given timezone (from ``tz_name``), and the date will be provided by
+    the parameters ``date1``, ``date2``, and ``date3``.
+    """
+    reminder = remind.TimeReminder(
+        13, 37, 0, tz_name, date1, date2, date3, message='Hello')
+
+    timezone = pytz.timezone(tz_name)
+    test_today = timezone.localize(datetime(2019, 5, 4, 13, 35, 0))
+    assert reminder.get_duration(test_today) == expected1
+
+    test_today = timezone.localize(datetime(2019, 5, 4, 13, 38, 0))
+    assert reminder.get_duration(test_today) == expected2
+
+
+def test_timereminder_get_duration_different_timezone():
+    reminder = remind.TimeReminder(
+        13, 37, 0, 'Europe/Paris', None, None, None, message='Hello')
+
+    test_today = pytz.utc.localize(datetime(2019, 5, 4, 11, 35, 0))
+    assert reminder.get_duration(test_today) == 120
+
+    # 1 minute in the past is timedelta(-1, 86340)
+    # ie. yesterday + 86340 seconds
+    # ie. today one minute ago
+    test_today = pytz.utc.localize(datetime(2019, 5, 4, 11, 38, 0))
+    assert reminder.get_duration(test_today) == 86340
+
+
+TEST_INVALID_DATE_123 = (
+    # empty value
+    ('0000', '00', '00'),
+    ('00', '00', '0000'),
+    ('00', '00', '00'),
+    # year full but day and/or month empty
+    ('2019', '00', '00'),
+    ('2019', '00', '05'),
+    ('2019', '03', '00'),
+    ('00', '00', '2019'),
+    ('00', '05', '2019'),
+    ('03', '00', '2019'),
+    # no year provided but day and/or month empty
+    ('00', '00', None),
+    ('03', '00', None),
+    ('00', '03', None),
+    # month above 12
+    ('01', '13', None),
+    ('01', '13', '2019'),
+    ('01', '13', '19'),
+    ('2019', '13', '01'),
+    # day above 31
+    ('32', '12', None),
+    ('32', '12', '2019'),
+    ('32', '12', '19'),
+    ('2019', '12', '32'),
+)
+
+
+@pytest.mark.parametrize('date1, date2, date3', TEST_INVALID_DATE_123)
+def test_timereminder_get_duration_error(date1, date2, date3):
+    test_today = pytz.utc.localize(datetime(2019, 5, 4, 11, 35, 0))
+    reminder = remind.TimeReminder(
+        13, 37, 0, 'Europe/Paris', date1, date2, date3, message='Hello')
+
+    with pytest.raises(ValueError):
+        reminder.get_duration(test_today)
+
+
 def test_get_filename(sopel):
     filename = remind.get_filename(sopel)
     assert filename == os.path.join(