Repository: pimutils/khal
Pull request #600 (2017-03-07 11:09:00)

Be more precise about the at command #486
I finally found time to edit the man page.

Do whatever you want with this. I found it usefull, thus maybe it could be for other :)


Patch:
diff --git a/.travis.yml b/.travis.yml
index 9f952e6..114f3f9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -6,11 +6,18 @@ python:
     - 3.4
     - 3.5
     - 3.6
+    - 3.7-dev
 
 env:
-    - BUILD=tests
-    - BUILD=tests-negtz
-    - BUILD=style
+    - BUILD=py
+    - BUILD=negtz
+
+matrix:
+    include:
+      - python: 3.6
+        env: BUILD=style
+      - python: 3.6
+        env: BUILD=docs
 
 addons:
     apt:
@@ -21,4 +28,4 @@ install:
         - "pip install tox"
 
 script:
-    - "tox -e py-$BUILD"
+    - "tox -e $BUILD"
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index f7dbab3..bf6e604 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,8 +12,10 @@ may want to subscribe to `GitHub's tag feed
 * FIX ikhal's event editor now warns before allowing to edit recurrence rules it
   doesn't understand
 
-* NEW ikhal's event editor now allows setting INTERVALs in recurrences
-
+* NEW ikhal's event editor now allows better editing of recurrence rules,
+  including INTERVALs, end dates, and more
+* NEW ikhal will now check if any configured vdir has been updated, and, if
+  applicable, refresh its UI to reflect the latest changes 
 
 0.9.3
 =====
diff --git a/README.rst b/README.rst
index 6002132..a61b800 100644
--- a/README.rst
+++ b/README.rst
@@ -20,7 +20,6 @@ Features
   example CalDAV_ servers.
 - fast and easy way to add new events
 - ikhal (interactive khal) lets you browse and edit calendars and events
-- only rudimentary support for creating and editing recursion rules
 - no support for editing the timezones of events yet
 - works with python 3.3+
 - khal should run on all major operating systems [1]_
diff --git a/doc/source/install.rst b/doc/source/install.rst
index 7d9fbb8..d248fe8 100644
--- a/doc/source/install.rst
+++ b/doc/source/install.rst
@@ -12,7 +12,7 @@ versions), Debian_, Fedora_, FreeBSD_, Guix_, and pkgsrc_.
 .. _Fedora: https://admin.fedoraproject.org/pkgdb/package/rpms/khal/
 .. _FreeBSD: https://www.freshports.org/deskutils/py-khal/
 .. _Guix: http://www.gnu.org/software/guix/packages/
-.. _pkgsrc: http://pkgsrc.se/wip/khal-git
+.. _pkgsrc: http://pkgsrc.se/time/khal
 
 If a package isn't available (or it is outdated) you need to fall back to one
 of the methods mentioned below.
diff --git a/doc/source/usage.rst b/doc/source/usage.rst
index a17cebe..a78a7a2 100644
--- a/doc/source/usage.rst
+++ b/doc/source/usage.rst
@@ -227,8 +227,8 @@ start.
 
 ::
 
-        khal list [-a CALENDAR ... | -d CALENDAR ...] [--format FORMAT]
-        [--notstarted] [START DATE]
+        khal at [-a CALENDAR ... | -d CALENDAR ...] [--format FORMAT]
+        [--notstarted] [[START DATE] TIME | now]
 
 calendar
 ********
diff --git a/khal/cli.py b/khal/cli.py
index 77f7a51..a781f0d 100644
--- a/khal/cli.py
+++ b/khal/cli.py
@@ -581,7 +581,7 @@ def _get_cli():
                   help=('The format of the day line.'))
     @click.option('--notstarted', help=('Print only events that have not started'),
                   is_flag=True)
-    @click.argument('DATETIME', nargs=-1, required=False, metavar='[DATETIME]')
+    @click.argument('DATETIME', nargs=-1, required=False, metavar='[[START DATE] TIME | now]')
     @click.pass_context
     def at(ctx, datetime, notstarted, format, day_format):
         '''Print all events at a specific datetime (defaults to now).'''
diff --git a/khal/controllers.py b/khal/controllers.py
index bfe1e86..9700213 100644
--- a/khal/controllers.py
+++ b/khal/controllers.py
@@ -542,7 +542,7 @@ def import_ics(collection, conf, ics, batch=False, random_uid=False, format=None
     """
     if format is None:
         format = conf['view']['event_format']
-    vevents = utils.split_ics(ics, random_uid)
+    vevents = utils.split_ics(ics, random_uid, conf['locale']['default_timezone'])
     for vevent in vevents:
         import_event(vevent, collection, conf['locale'], batch, format, env)
 
diff --git a/khal/khalendar/backend.py b/khal/khalendar/backend.py
index df3e075..ed845f4 100644
--- a/khal/khalendar/backend.py
+++ b/khal/khalendar/backend.py
@@ -160,7 +160,7 @@ class SQLiteDb(object):
         """
         self.cursor.execute('CREATE TABLE IF NOT EXISTS '
                             'version (version INTEGER)')
-        logger.debug(u"created version table")
+        logger.debug("created version table")
 
         self.cursor.execute('''CREATE TABLE IF NOT EXISTS calendars (
             calendar TEXT NOT NULL UNIQUE,
@@ -207,7 +207,7 @@ class SQLiteDb(object):
             result = self.cursor.fetchone()
 
             if result[0] != 0:
-                logger.debug(u"tables for calendar {0} exist".format(cal))
+                logger.debug("tables for calendar {0} exist".format(cal))
             else:
                 sql_s = 'INSERT INTO calendars (calendar, resource) VALUES (?, ?);'
                 stuple = (cal, '')
diff --git a/khal/khalendar/event.py b/khal/khalendar/event.py
index 8a05d54..f3b7c37 100644
--- a/khal/khalendar/event.py
+++ b/khal/khalendar/event.py
@@ -698,7 +698,7 @@ class AllDayEvent(Event):
             # https://github.com/pimutils/khal/issues/129
             logger.warning('{} ("{}"): The event\'s end date property '
                            'contains the same value as the start date, '
-                           'which is invalid as per RFC 2445. Khal will '
+                           'which is invalid as per RFC 5545. Khal will '
                            'assume this is meant to be single-day event '
                            'on {}'.format(self.href, self.summary, self.start))
             end += timedelta(days=1)
diff --git a/khal/khalendar/khalendar.py b/khal/khalendar/khalendar.py
index 527a182..62598dc 100644
--- a/khal/khalendar/khalendar.py
+++ b/khal/khalendar/khalendar.py
@@ -96,6 +96,7 @@ class CalendarCollection(object):
         self._locale = locale
         self._backend = backend.SQLiteDb(
             calendars=self.names, db_path=dbpath, locale=self._locale)
+        self._last_ctags = dict()
         self.update_db()
 
     @property
@@ -236,15 +237,45 @@ class CalendarCollection(object):
         should be called after every change to the vdir
         """
         for calendar in self._calendars:
-            if self._needs_update(calendar):
+            if self._needs_update(calendar, remember=True):
                 self._db_update(calendar)
 
-    def _needs_update(self, calendar):
+    def needs_update(self):
+        """Check if you need to call update_db.
+
+        This could either be the case because the vdirs were changed externally,
+        or another instance of khal updated the caching db already.
+        """
+        # TODO is it a good idea to munch both use cases together?
+        # in case another instance of khal has updated the db, we only need
+        # to get new events, but # update_db() takes potentially a long time to return
+        # but then the code (in ikhal's refresh code) would need to look like
+        # this:
+        #
+        # update_ui = False
+        # if collection.needs_update():
+        #   collection.update_db()
+        #   update_ui = True
+        # if collection.needs_refresh() or update_ui:
+        #   do_the_update()
+        #
+        # and the API would be made even uglier than it already is...
+        for calendar in self._calendars:
+            if self._needs_update(calendar) or \
+                    self._last_ctags[calendar] != self._local_ctag(calendar):
+                return True
+        return False
+
+    def _needs_update(self, calendar, remember=False):
         """checks if the db for the given calendar needs an update"""
-        return self._local_ctag(calendar) != self._backend.get_ctag(calendar)
+        local_ctag = self._local_ctag(calendar)
+        if remember:
+            self._last_ctags[calendar] = local_ctag
+        return local_ctag != self._backend.get_ctag(calendar)
 
     def _db_update(self, calendar):
         """implements the actual db update on a per calendar base"""
+        local_ctag = self._local_ctag(calendar)
         db_hrefs = set(href for href, etag in self._backend.list(calendar))
         storage_hrefs = set()
 
@@ -257,7 +288,8 @@ class CalendarCollection(object):
                     self._update_vevent(href, calendar=calendar)
             for href in db_hrefs - storage_hrefs:
                 self._backend.delete(href, calendar=calendar)
-            self._backend.set_ctag(self._local_ctag(calendar), calendar=calendar)
+            self._backend.set_ctag(local_ctag, calendar=calendar)
+            self._last_ctags[calendar] = local_ctag
 
     def _update_vevent(self, href, calendar):
         """should only be called during db_update, only updates the db,
diff --git a/khal/khalendar/vdir.py b/khal/khalendar/vdir.py
index b0c4f72..591ab8d 100644
--- a/khal/khalendar/vdir.py
+++ b/khal/khalendar/vdir.py
@@ -121,7 +121,7 @@ class Item:
 
     @cached_property
     def uid(self):
-        uid = u''
+        uid = ''
         lines = iter(self.raw.splitlines())
         for line in lines:
             if line.startswith('UID:'):
@@ -129,7 +129,7 @@ class Item:
                 break
 
         for line in lines:
-            if not line.startswith(u' '):
+            if not line.startswith(' '):
                 break
             uid += line[1:]
 
@@ -137,7 +137,7 @@ class Item:
 
 
 def _normalize_meta_value(value):
-    return to_unicode(value or u'').strip()
+    return to_unicode(value or '').strip()
 
 
 class VdirBase:
@@ -273,7 +273,7 @@ class VdirBase:
                 raise
 
     def set_meta(self, key, value):
-        value = value or u''
+        value = value or ''
         assert isinstance(value, str)
         fpath = os.path.join(self.path, key)
         with atomic_write(fpath, mode='wb', overwrite=True) as f:
diff --git a/khal/log.py b/khal/log.py
index 6afdb26..dda5cd8 100644
--- a/khal/log.py
+++ b/khal/log.py
@@ -48,7 +48,7 @@ class ColorFormatter(logging.Formatter):
         return logging.Formatter.format(self, record)
 
 
-class ClickStream(object):
+class ClickStream:
 
     def write(self, string):
         click.echo(string, file=sys.stderr, nl=False)
diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py
index 1386e3d..41a5d9e 100644
--- a/khal/ui/__init__.py
+++ b/khal/ui/__init__.py
@@ -140,7 +140,7 @@ class DateHeader(SelectableText):
         return '{weekday}, {day} ({approx_delta})'.format(
             weekday=weekday,
             approx_delta=approx_delta,
-            day=day.strftime('%A'),
+            day=daystr,
         )
 
     def keypress(self, _, key):
@@ -691,7 +691,7 @@ class EventColumn(urwid.WidgetWrap):
         """
         if event.readonly:
             self.pane.window.alert(
-                ('light red', 'Calendar {} is read-only.'.format(event.calendar)))
+                ('alert', 'Calendar `{}` is read-only.'.format(event.calendar)))
             return
 
         if isinstance(event.start_local, datetime):
@@ -769,12 +769,10 @@ class EventColumn(urwid.WidgetWrap):
                 self.focus_event.event.export_ics(user_data.get_edit_text())
             except Exception as error:
                 self.pane.window.backtrack()
-                self.pane.window.alert(
-                    ('light red', 'Failed to save event: %s' % error))
+                self.pane.window.alert(('alert', 'Failed to save event: %s' % error))
             else:
                 self.pane.window.backtrack()
-                self.pane.window.alert(
-                    ('light green', 'Event successfully exported'))
+                self.pane.window.alert('Event successfully exported')
 
         overlay = urwid.Overlay(
             ExportDialog(
@@ -803,9 +801,9 @@ class EventColumn(urwid.WidgetWrap):
                 event.event.start_local, event.event.end_local, event.event.recurring)
 
         if event.event.readonly:
-            self.eventcolumn.pane.window.alert(
-                ('light red',
-                 'Calendar {} is read-only.'.format(self.event.calendar)))
+            self.pane.window.alert(
+                ('alert', 'Calendar {} is read-only.'.format(event.event.calendar)),
+            )
             return
         status = self.delete_status(event.recuid)
         refresh = True
@@ -865,7 +863,7 @@ class EventColumn(urwid.WidgetWrap):
         :type end: datetime.date
         """
         if not self.pane.collection.writable_names:
-            self.pane.window.alert(('light red', 'No writable calendar.'))
+            self.pane.window.alert(('alert', 'No writable calendar.'))
             return
         if end is None:
             start = datetime.combine(date, time(datetime.now().hour))
@@ -912,7 +910,7 @@ class EventColumn(urwid.WidgetWrap):
                     # the event in focus is already viewed -> edit
                     self.clear_event_view()  # do not move before the if condition
                     if self.delete_status(self.focus_event.recuid):
-                        self.pane.window.alert(('light red', 'This event is marked as deleted'))
+                        self.pane.window.alert(('alert', 'This event is marked as deleted'))
                     self.edit(self.focus_event.event)
                 else:
                     self.clear_event_view()
@@ -1267,8 +1265,6 @@ def start_pane(pane, callback, program_info='', quit_keys=['q']):
     frame.loop = loop
 
     def redraw_today(loop, pane, meta={'last_today': None}):
-        # TODO calculate how many seconds the new day is away and set timer
-        # to that
         # XXX TODO this currently assumes, today moves forward by exactly one
         # day, but it could either move forward more (suspend-to-disk/ram) or
         # even move backwards
@@ -1279,7 +1275,17 @@ def start_pane(pane, callback, program_info='', quit_keys=['q']):
             pane.eventscolumn.original_widget.update_date_line()
         loop.set_alarm_in(60, redraw_today, pane)
 
-    redraw_today(frame.loop, pane)
+    loop.set_alarm_in(60, redraw_today, pane)
+
+    def check_for_updates(loop, pane):
+        if pane.collection.needs_update():
+            pane.window.alert('detected external vdir modification, updating...')
+            pane.collection.update_db()
+            pane.eventscolumn.base_widget.update(None, None, everything=True)
+            pane.window.alert('detected external vdir modification, updated.')
+        loop.set_alarm_in(60, check_for_updates, pane)
+
+    loop.set_alarm_in(60, check_for_updates, pane)
     # Make urwid use 256 color mode.
     loop.screen.set_terminal_properties(
         colors=256, bright_is_bold=pane._conf['view']['bold_for_light_color'])
diff --git a/khal/ui/base.py b/khal/ui/base.py
index d8cbde1..4ab6edc 100644
--- a/khal/ui/base.py
+++ b/khal/ui/base.py
@@ -111,9 +111,9 @@ class Window(urwid.Frame):
 
         header = urwid.AttrWrap(urwid.Text(''), 'header')
         footer = urwid.AttrWrap(urwid.Text(footer), 'footer')
-        urwid.Frame.__init__(self, urwid.Text(''),
-                             header=header,
-                             footer=footer)
+        urwid.Frame.__init__(
+            self, urwid.Text(''), header=header, footer=footer,
+        )
         self.update_header()
         self._original_w = None
         self.quit_keys = quit_keys
@@ -172,7 +172,13 @@ class Window(urwid.Frame):
         return self._track[-1][0] if self._track else None
 
     def update_header(self, alert=None):
-        # TODO setting colors does not work, but is actually used
+        """Update the Windows header line.
+
+        :param alert: additional text to show in header, additionally to
+            the current title. If `alert` is a tuple, the first entry must
+            be a valid palette entry
+        :type alert: str or (palette_entry, str)
+        """
         pane_title = getattr(self._get_current_pane(), 'title', None)
         text = []
 
diff --git a/khal/ui/calendarwidget.py b/khal/ui/calendarwidget.py
index e3cf093..aaf7732 100644
--- a/khal/ui/calendarwidget.py
+++ b/khal/ui/calendarwidget.py
@@ -19,7 +19,7 @@
 # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
-"""contains a re-usable CalendarWidget for urwid
+"""Contains a re-usable CalendarWidget for urwid.
 
 if anything doesn't work as expected, please open an issue for khal
 """
@@ -334,8 +334,7 @@ class CListBox(urwid.ListBox):
             return self.on_press[key](start, end)
         if key in self.keybindings['today'] + ['page down', 'page up']:
             # reset colors of currently focused Date widget
-            self.focus.focus.set_styles(
-                self.focus.get_styles(self.body.focus_date, False))
+            self.focus.focus.set_styles(self.focus.get_styles(self.body.focus_date, False))
         if key in self.keybindings['today']:
             self.set_focus_date(date.today())
             self.set_focus_valign(('relative', 10))
@@ -554,28 +553,28 @@ class CalendarWalker(urwid.SimpleFocusListWalker):
 class CalendarWidget(urwid.WidgetWrap):
     def __init__(self, on_date_change, keybindings, on_press, firstweekday=0,
                  weeknumbers=False, get_styles=None, initial=None):
-
         """
         :param on_date_change: a function that is called every time the selected
             date is changed with the newly selected date as a first (and only
             argument)
         :type on_date_change: function
-        :param keybindings: bind keys to specific functions, keys are
-            commands (e.g. movement commands, values are lists of keys
-            that should be bound to those commands. See below for the
-            defaults.
+        :param keybindings: bind keys to specific functionionality, keys are
+            the available commands, values are lists of keys that should be
+            bound to those commands. See below for the defaults.
             Available commands:
                 'left', 'right', 'up', 'down': move cursor in direction
                 'today': refocus on today
                 'mark': toggles selection mode
         :type keybindings: dict
-        :param on_press: dict of functions that are called when the key is
-            pressed. These functions must accept at least two argument. In the
-            normal case the first argument is the currently selected date
-            (datetime.date) and the second is *None*. When a date range is
-            selected, the first argument is the earlier and the second argument
+        :param on_press: dictonary of functions that are called when the key is
+            pressed and is not already bound to one of the internal functionions
+            via `keybindings`. These functions must accept two arguments, in
+            normal mode the first argument is the currently selected date
+            (datetime.date) and the second is `None`. When a date range is
+            selected, the first argument is the earlier, the second argument
             is the later date. The function's return values are interpreted as
-            pressed keys.
+            pressed keys, which are handed to the widget containing the
+            CalendarWidget.
         :type on_press: dict
         """
         if initial is None:
diff --git a/khal/ui/editor.py b/khal/ui/editor.py
index 5a0b985..ded2002 100644
--- a/khal/ui/editor.py
+++ b/khal/ui/editor.py
@@ -20,17 +20,15 @@
 # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
 from datetime import datetime, time
+import datetime as dt
 
 import urwid
 
-from urwid import Edit
-
+from ..utils import get_weekday_occurrence
 from .widgets import DateWidget, TimeWidget, NColumns, NPile, ValidatedEdit, \
-    DateConversionError, Choice, PositiveIntEdit, AlarmsEditor, NListBox
+    DateConversionError, Choice, PositiveIntEdit, AlarmsEditor, NListBox, ExtendedEdit
 from .calendarwidget import CalendarWidget
 
-NOREPEAT = 'No'
-
 
 class StartEnd(object):
 
@@ -43,9 +41,11 @@ class StartEnd(object):
 
 
 class CalendarPopUp(urwid.PopUpLauncher):
-    def __init__(self, widget, conf, on_date_change):
-        self._conf = conf
+    def __init__(self, widget, on_date_change, weeknumbers=False, firstweekday=0, keybindings=None):
         self._on_date_change = on_date_change
+        self._weeknumbers = weeknumbers
+        self._firstweekday = firstweekday
+        self._keybindings = {} if keybindings is None else keybindings
         self.__super.__init__(widget)
 
     def keypress(self, size, key):
@@ -59,7 +59,6 @@ class CalendarPopUp(urwid.PopUpLauncher):
             self._get_base_widget().set_value(new_date)
             self._on_date_change(new_date)
 
-        keybindings = self._conf['keybindings']
         on_press = {'enter': lambda _, __: self.close_pop_up(),
                     'esc': lambda _, __: self.close_pop_up()}
         try:
@@ -68,40 +67,98 @@ class CalendarPopUp(urwid.PopUpLauncher):
             return None
         else:
             pop_up = CalendarWidget(
-                on_change, keybindings, on_press,
-                firstweekday=self._conf['locale']['firstweekday'],
-                weeknumbers=self._conf['locale']['weeknumbers'],
+                on_change, self._keybindings, on_press,
+                firstweekday=self._firstweekday,
+                weeknumbers=self._weeknumbers,
                 initial=initial_date)
             pop_up = urwid.LineBox(pop_up)
             return pop_up
 
     def get_pop_up_parameters(self):
-        width = 31 if self._conf['locale']['weeknumbers'] == 'right' else 28
+        width = 31 if self._weeknumbers == 'right' else 28
         return {'left': 0, 'top': 1, 'overlay_width': width, 'overlay_height': 8}
 
 
+class DateEdit(urwid.WidgetWrap):
+    """Widget that allows editing a Date.
+
+    Will open a calendar when `enter` is pressed, pressing `enter` again will
+    select that date.
+    """
+
+    def __init__(
+            self, startdt=None, dateformat='%Y-%m-%d',
+            on_date_change=lambda _: None,
+            weeknumbers=False, firstweekday=0, keybindings=None,
+    ):
+        datewidth = len(startdt.strftime(dateformat)) + 1
+        self._dateformat = dateformat
+        if startdt is None:
+            startdt = dt.date.today()
+        self._edit = ValidatedEdit(
+            dateformat=dateformat,
+            EditWidget=DateWidget,
+            validate=self._validate,
+            edit_text=startdt.strftime(dateformat),
+            on_date_change=on_date_change)
+        wrapped = CalendarPopUp(self._edit, on_date_change, weeknumbers, firstweekday, keybindings)
+        padded = urwid.Padding(wrapped, align='left', width=datewidth, left=0, right=1)
+        super().__init__(padded)
+
+    def _validate(self, text):
+        try:
+            _date = datetime.strptime(text, self._dateformat).date()
+        except ValueError:
+            return False
+        else:
+            return _date
+
+    @property
+    def date(self):
+        """Get currently entered date, or False, if input is invalid.
+
+        :returns: the currently entered date
+        :rtype: datetime.date
+        """
+        return self._validate(self._edit.get_edit_text())
+
+    @date.setter
+    def date(self, date):
+        """Update text of this Widget.
+
+        :type date: datetime.date
+        """
+        self._edit.set_edit_text(date.strftime(self._dateformat))
+
+
 class StartEndEditor(urwid.WidgetWrap):
-    """Widget for editing start and end times (of an event)"""
+    """Widget for editing start and end times (of an event)."""
 
-    def __init__(self, start, end, conf, on_date_change=lambda x: None):
+    def __init__(self, start, end, conf,
+                 on_start_date_change=lambda x: None,
+                 on_end_date_change=lambda x: None,
+
+                 ):
         """
         :type start: datetime.datetime
         :type end: datetime.datetime
-        :param on_date_change: a callable that gets called everytime a new
-            date is entered, with that new date as an argument
+        :param on_start_date_change: a callable that gets called everytime a new
+            start date is entered, with that new date as an argument
+        :param on_end_date_change: same as for on_start_date_change, just for the
+            end date
         """
         self.allday = not isinstance(start, datetime)
         self.conf = conf
         self._startdt, self._original_start = start, start
         self._enddt, self._original_end = end, end
-        self.on_date_change = on_date_change
+        self.on_start_date_change = on_start_date_change
+        self.on_end_date_change = on_end_date_change
         self._datewidth = len(start.strftime(self.conf['locale']['longdateformat']))
         self._timewidth = len(start.strftime(self.conf['locale']['timeformat']))
         # this will contain the widgets for [start|end] [date|time]
         self.widgets = StartEnd(None, None, None, None)
-
-        self.checkallday = urwid.CheckBox('Allday', state=self.allday,
-                                          on_state_change=self.toggle)
+        self.checkallday = urwid.CheckBox(
+            'Allday', state=self.allday, on_state_change=self.toggle)
         self.toggle(None, self.allday)
 
     def keypress(self, size, key):
@@ -159,15 +216,9 @@ class StartEndEditor(urwid.WidgetWrap):
         else:
             return startval
 
-    def _validate_start_date(self, text):
-        try:
-            startval = datetime.strptime(text, self.conf['locale']['longdateformat'])
-            self._startdt = self.localize_start(
-                datetime.combine(startval.date(), self._start_time))
-        except ValueError:
-            return False
-        else:
-            return startval
+    def _start_date_change(self, date):
+        self._startdt = self.localize_start(datetime.combine(date, self._start_time))
+        self.on_start_date_change(date)
 
     def _validate_end_time(self, text):
         try:
@@ -178,14 +229,9 @@ class StartEndEditor(urwid.WidgetWrap):
         else:
             return endval
 
-    def _validate_end_date(self, text):
-        try:
-            endval = datetime.strptime(text, self.conf['locale']['longdateformat'])
-            self._enddt = self.localize_end(datetime.combine(endval.date(), self._end_time))
-        except ValueError:
-            return False
-        else:
-            return endval
+    def _end_date_change(self, date):
+        self._enddt = self.localize_end(datetime.combine(date, self._start_time))
+        self.on_end_date_change(date)
 
     def toggle(self, checkbox, state):
         """change from allday to datetime event
@@ -205,30 +251,16 @@ class StartEndEditor(urwid.WidgetWrap):
             self._startdt = self._startdt.date()
             self._enddt = self._enddt.date()
         self.allday = state
-        datewidth = self._datewidth + 7
-        # startdate
-        edit = ValidatedEdit(
-            dateformat=self.conf['locale']['longdateformat'],
-            EditWidget=DateWidget,
-            validate=self._validate_start_date,
-            caption=('', 'From: '),
-            edit_text=self.startdt.strftime(self.conf['locale']['longdateformat']),
-            on_date_change=self.on_date_change)
-        edit = CalendarPopUp(edit, self.conf, self.on_date_change)
-        edit = urwid.Padding(edit, align='left', width=datewidth, left=0, right=1)
-        self.widgets.startdate = edit
-
-        # enddate
-        edit = ValidatedEdit(
-            dateformat=self.conf['locale']['longdateformat'],
-            EditWidget=DateWidget,
-            validate=self._validate_end_date,
-            caption=('', 'To:   '),
-            edit_text=self.enddt.strftime(self.conf['locale']['longdateformat']),
-            on_date_change=self.on_date_change)
-        edit = CalendarPopUp(edit, self.conf, self.on_date_change)
-        edit = urwid.Padding(edit, align='left', width=datewidth, left=0, right=1)
-        self.widgets.enddate = edit
+        self.widgets.startdate = DateEdit(
+            self._startdt, self.conf['locale']['longdateformat'],
+            self._start_date_change, self.conf['locale']['weeknumbers'],
+            self.conf['locale']['firstweekday'], self.conf['keybindings'],
+        )
+        self.widgets.enddate = DateEdit(
+            self._enddt, self.conf['locale']['longdateformat'],
+            self._end_date_change, self.conf['locale']['weeknumbers'],
+            self.conf['locale']['firstweekday'], self.conf['keybindings'],
+        )
 
         if state is True:
             timewidth = 1
@@ -258,10 +290,10 @@ class StartEndEditor(urwid.WidgetWrap):
 
         columns = NPile([
             self.checkallday,
-            NColumns([(datewidth, self.widgets.startdate), (
+            NColumns([(5, urwid.Text('From:')), (self._datewidth, self.widgets.startdate), (
                 timewidth, self.widgets.starttime)], dividechars=1),
             NColumns(
-                [(datewidth, self.widgets.enddate),
+                [(5, urwid.Text('To:')), (self._datewidth, self.widgets.enddate),
                  (timewidth, self.widgets.endtime)],
                 dividechars=1)
         ], focus_item=1)
@@ -288,7 +320,6 @@ class EventEditor(urwid.WidgetWrap):
         :param always_save: save event even if it has not changed
         :type always_save: bool
         """
-
         self.pane = pane
         self.event = event
         self._save_callback = save_callback
@@ -303,11 +334,14 @@ class EventEditor(urwid.WidgetWrap):
         self.categories = event.categories
         self.startendeditor = StartEndEditor(
             event.start_local, event.end_local, self._conf,
-            self.pane.eventscolumn.original_widget.set_focus_date)
+            self.start_datechange, self.end_datechange,
+        )
+        # TODO make sure recurrence rules cannot be edited if we only
+        # edit one instance (or this and future) (once we support that)
         self.recurrenceeditor = RecurrenceEditor(
             self.event.recurobject, self._conf, event.start_local,
         )
-        self.summary = urwid.Edit(caption='Title: ', edit_text=event.summary)
+        self.summary = ExtendedEdit(caption='Title: ', edit_text=event.summary)
 
         divider = urwid.Divider(' ')
 
@@ -319,9 +353,11 @@ class EventEditor(urwid.WidgetWrap):
             self.collection._calendars[self.event.calendar],
             decorate_choice
         )
-        self.description = Edit(caption='Description: ', edit_text=self.description, multiline=True)
-        self.location = Edit(caption='Location: ', edit_text=self.location)
-        self.categories = Edit(caption='Categories: ', edit_text=self.categories)
+        self.description = ExtendedEdit(
+            caption='Description: ', edit_text=self.description, multiline=True,
+        )
+        self.location = ExtendedEdit(caption='Location: ', edit_text=self.location)
+        self.categories = ExtendedEdit(caption='Categories: ', edit_text=self.categories)
         self.alarms = AlarmsEditor(self.event)
         self.pile = NListBox(urwid.SimpleFocusListWalker([
             NColumns([self.summary, self.calendar_chooser], dividechars=2),
@@ -341,6 +377,13 @@ class EventEditor(urwid.WidgetWrap):
         self._always_save = always_save
         urwid.WidgetWrap.__init__(self, self.pile)
 
+    def start_datechange(self, date):
+        self.pane.eventscolumn.original_widget.set_focus_date(date)
+        self.recurrenceeditor.update_startdt(date)
+
+    def end_datechange(self, date):
+        self.pane.eventscolumn.original_widget.set_focus_date(date)
+
     @property
     def title(self):  # Window title
         return 'Edit: {}'.format(self.summary.get_edit_text())
@@ -423,6 +466,7 @@ class EventEditor(urwid.WidgetWrap):
             self.pane.window.alert(
                 ('light red', "Can't save: end date is before start date!"))
             return
+
         if self._always_save or self.changed is True:
             self.update_vevent()
             self.event.allday = self.startendeditor.allday
@@ -459,18 +503,45 @@ class EventEditor(urwid.WidgetWrap):
         return super().keypress(size, key)
 
 
+WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']  # TODO use locale and respect weekdaystart
+
+
+class WeekDaySelector(urwid.WidgetWrap):
+    def __init__(self, startdt, selected_days):
+
+        self._weekday_boxes = {day: urwid.CheckBox(day, state=False) for day in WEEKDAYS}
+        weekday = startdt.weekday()
+        self._weekday_boxes[WEEKDAYS[weekday]].state = True
+        self.weekday_checks = NColumns([(7, self._weekday_boxes[wd]) for wd in WEEKDAYS])
+        for day in selected_days:
+            self._weekday_boxes[day].state = True
+        urwid.WidgetWrap.__init__(self, self.weekday_checks)
+
+    @property
+    def days(self):
+        days = [day.label for (day, _) in self.weekday_checks.contents if day.state]
+        return days
+
+
 class RecurrenceEditor(urwid.WidgetWrap):
 
     def __init__(self, rrule, conf, startdt):
-        # TODO: support more recurrence schemes
-        #
         self._conf = conf
-        self._startdt = startdt  # TODO make sure updated when startdt is updated in the editor
+        self._startdt = startdt
         self._rrule = rrule
         self.repeat = bool(rrule)
         self._allow_edit = not self.repeat or self.check_understood_rrule(rrule)
+        self.repeat_box = urwid.CheckBox(
+            'Repeat: ', state=self.repeat, on_state_change=self.check_repeat,
+        )
+
+        if "UNTIL" in self._rrule:
+            self._until = "Until"
+        elif "COUNT" in self._rrule:
+            self._until = "Repetitions"
+        else:
+            self._until = "Forever"
 
-        self._until = "Forever"
         recurrence = self._rrule['freq'][0].lower() if self._rrule else "weekly"
         self.recurrence_choice = Choice(
             ["daily", "weekly", "monthly", "yearly"],
@@ -481,82 +552,118 @@ class RecurrenceEditor(urwid.WidgetWrap):
             caption='every:',
             edit_text=str(self._rrule.get('INTERVAL', [1])[0]),
         )
-        self.repeat_box = urwid.CheckBox(
-            'Repeat: ', state=self.repeat, on_state_change=self.check_repeat,
-        )
         self.until_choice = Choice(
             ["Forever", "Until", "Repetitions"], self._until, callback=self.rebuild,
         )
-        self.repetitions_edit = PositiveIntEdit(edit_text='1')
-        # startdate
-        edit = ValidatedEdit(
-            dateformat=self._conf['locale']['longdateformat'],
-            EditWidget=DateWidget,
-            validate=lambda _: True,  # TODO check if valid date and in future
-            edit_text=self._startdt.strftime(self._conf['locale']['longdateformat']),
-            on_date_change=lambda _: None,
+
+        count = str(self._rrule.get('COUNT', [1])[0])
+        self.repetitions_edit = PositiveIntEdit(edit_text=count)
+
+        until = self._rrule.get('UNTIL', [None])[0]
+        if until is None and isinstance(self._startdt, datetime):
+            until = self._startdt.date()
+        elif until is None:
+            until = self._startdt
+
+        if isinstance(until, datetime):
+            until = datetime.date()
+        self.until_edit = DateEdit(
+            until, self._conf['locale']['longdateformat'],
+            lambda _: None, self._conf['locale']['weeknumbers'],
+            self._conf['locale']['firstweekday'],
         )
-        self.until_edit = CalendarPopUp(edit, self._conf, lambda _: None)
-        # TODO use locale and respect weekdaystart
-        weekdays = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
-
-        self._weekday_boxes = {day: urwid.CheckBox(day, state=False) for day in weekdays}
-        weekday = self._startdt.weekday()  # TODO also needs fixing
-        self._weekday_boxes[weekdays[weekday]].state = True
-        # TODO make sure weekday of startdt is always selected (or perhaps
-        # not un-selectable)
-
-        xth_weekday = "every {} {}".format(
-            (self._startdt.day // 7) + 1, weekdays[self._startdt.weekday()],
+
+        self._rebuild_weekday_checks()
+        self._rebuild_monthly_choice()
+        self._pile = pile = NPile([urwid.Text('')])
+        urwid.WidgetWrap.__init__(self, pile)
+        self.rebuild()
+
+    def _rebuild_monthly_choice(self):
+        weekday, xth = get_weekday_occurrence(self._startdt)
+        ords = {1: 'st', 2: 'nd', 3: 'rd', 21: 'st', 22: 'nd', 23: 'rd', 31: 'st'}
+        self._xth_weekday = 'on every {}{} {}'.format(
+            xth, ords.get(xth, 'th'), WEEKDAYS[weekday],
+        )
+        self._xth_monthday = 'on every {}{} of the month'.format(
+            self._startdt.day, ords.get(self._startdt.day, 'th'),
         )
         self.monthly_choice = Choice(
-            ["on the xth", xth_weekday], "on the xth", callback=self.rebuild,
+            [self._xth_monthday, self._xth_weekday], self._xth_monthday, callback=self.rebuild,
         )
-        self.weekday_checks = NColumns([(7, self._weekday_boxes[wd]) for wd in weekdays])
 
-        self._pile = pile = NPile([urwid.Text('')])
-        urwid.WidgetWrap.__init__(self, pile)
+    def _rebuild_weekday_checks(self):
+        if self.recurrence_choice.active == 'weekly':
+            initial_days = self._rrule.get('BYDAY', [])
+        else:
+            initial_days = []
+        self.weekday_checks = WeekDaySelector(self._startdt, initial_days)
+
+    def update_startdt(self, startdt):
+        self._startdt = startdt
+        self._rebuild_weekday_checks()
+        self._rebuild_monthly_choice()
         self.rebuild()
 
-    def check_understood_rrule(self, rrule):
-        """test if we can reporduce `rrule`"""
+    @staticmethod
+    def check_understood_rrule(rrule):
+        """test if we can reproduce `rrule`."""
         keys = set(rrule.keys())
-        if keys.difference({'FREQ', 'INTERVAL'}) or \
-                rrule.get('FREQ', [None])[0] not in ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']:
+        freq = rrule.get('FREQ', [None])[0]
+        unsupported_rrule_parts = {
+            'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYYEARDAY',
+            'BYWEEKNO', 'BYMONTH', 'BYSETPOS',
+        }
+        if keys.intersection(unsupported_rrule_parts):
             return False
-        else:
-            return True
+        # we don't support negative BYMONTHDAY numbers
+        if rrule.get('BYMONTHDAY', ['1'])[0][0] == '-':
+            return False
+        if rrule.get('BYDAY', ['1'])[0][0] == '-':
+            return False
+        if freq not in ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']:
+            return False
+        if 'BYDAY' in keys and freq == 'YEARLY':
+            return False
+        return True
 
     def check_repeat(self, checkbox, state):
         self.repeat = state
         self.rebuild()
 
+    def _refill_contents(self, lines):
+        while True:
+            try:
+                self._pile.contents.pop()
+            except IndexError:
+                break
+        [self._pile.contents.append((line, ('pack', None))) for line in lines]
+
     def rebuild(self):
-        # TODO FIXME after warning button gets pressed, widget is not redrawn
         old_focus_y = self._pile.focus_position
         if not self._allow_edit:
             self._rebuild_no_edit()
         elif self.repeat:
             self._rebuild_edit()
+            self._pile.set_focus(old_focus_y)
         else:
             self._rebuild_edit_no_repeat()
-        self._pile.focus_position = old_focus_y
 
     def _rebuild_no_edit(self):
         def _allow_edit(_):
             self._allow_edit = True
             self.rebuild()
-
-        pile = NPile([
-            urwid.Text("We cannot reproduce this event's repetion rules."),
-            urwid.Text("Editing the repetion rules will destroy the current rules."),
+        lines = [
+            urwid.Text("We cannot reproduce this event's repetition rules."),
+            urwid.Text("Editing the repetition rules will destroy the current rules."),
             urwid.Button("Edit anyway", on_press=_allow_edit),
-        ])
-        urwid.WidgetWrap.__init__(self, pile)
+        ]
+        self._refill_contents(lines)
+        self._pile.set_focus(2)
 
     def _rebuild_edit_no_repeat(self):
-        pile = NPile([NColumns([(13, self.repeat_box)])])
-        urwid.WidgetWrap.__init__(self, pile)
+        lines = [NColumns([(13, self.repeat_box)])]
+        self._refill_contents(lines)
 
     def _rebuild_edit(self):
         firstline = NColumns([
@@ -566,17 +673,39 @@ class RecurrenceEditor(urwid.WidgetWrap):
         ])
         lines = [firstline]
 
-        self._pile = NPile(lines)
-        urwid.WidgetWrap.__init__(self, self._pile)
+        if self.recurrence_choice.active == "weekly":
+            lines.append(self.weekday_checks)
+        if self.recurrence_choice.active == "monthly":
+            lines.append(self.monthly_choice)
+
+        nextline = [(16, self.until_choice)]
+        if self.until_choice.active == "Until":
+            nextline.append((20, self.until_edit))
+        elif self.until_choice.active == "Repetitions":
+            nextline.append((4, self.repetitions_edit))
+        lines.append(NColumns(nextline))
+
+        self._refill_contents(lines)
 
     @property
     def changed(self):
         return self._rrule != self.rrule()  # TODO do this properly
 
     def rrule(self):
-        rrule = dict()  # TODO check if we need to use a caseless dict
-        rrule["freq"] = [self.recurrence_choice.active]
-        rrule["interval"] = [int(self.interval_edit.get_edit_text())]
+        rrule = dict()
+        rrule['freq'] = [self.recurrence_choice.active]
+        interval = int(self.interval_edit.get_edit_text())
+        if interval != 1:
+            rrule['interval'] = [interval]
+        if rrule['freq'] == ['weekly'] and len(self.weekday_checks.days) > 1:
+            rrule['byday'] = self.weekday_checks.days
+        if rrule['freq'] == ['monthly'] and self.monthly_choice.active == self._xth_weekday:
+            weekday, occurrence = get_weekday_occurrence(self._startdt)
+            rrule['byday'] = ['{}{}'.format(occurrence, WEEKDAYS[weekday])]
+        if self.until_choice.active == 'Until':
+            rrule['until'] = self.until_edit.date
+        elif self.until_choice.active == 'Repetitions':
+            rrule['count'] = int(self.repetitions_edit.get_edit_text())
         return rrule
 
     @property
@@ -597,7 +726,7 @@ class ExportDialog(urwid.WidgetWrap):
         lines = []
         lines.append(urwid.Text('Export event as ICS file'))
         lines.append(urwid.Text(''))
-        export_location = Edit(
+        export_location = ExtendedEdit(
             caption='Location: ', edit_text="~/%s.ics" % event.summary.strip())
         lines.append(export_location)
         lines.append(urwid.Divider(' '))
diff --git a/khal/ui/widgets.py b/khal/ui/widgets.py
index c3faec5..74e9b6f 100644
--- a/khal/ui/widgets.py
+++ b/khal/ui/widgets.py
@@ -389,8 +389,7 @@ class NListBox(SupportsNext, urwid.ListBox):
 
 
 class ValidatedEdit(urwid.WidgetWrap):
-    def __init__(
-            self, *args, EditWidget=ExtendedEdit, validate=False, **kwargs):
+    def __init__(self, *args, EditWidget=ExtendedEdit, validate=False, **kwargs):
         assert validate
         self._validate_func = validate
         self._original_widget = urwid.AttrMap(EditWidget(*args, **kwargs), 'edit', 'editf')
@@ -439,7 +438,7 @@ class ValidatedEdit(urwid.WidgetWrap):
 
 class PositiveIntEdit(ValidatedEdit):
     def __init__(self, *args, EditWidget=ExtendedEdit, validate=False, **kwargs):
-        """Variant of Validated Edit that only accepts positive integers"""
+        """Variant of Validated Edit that only accepts positive integers."""
         super().__init__(*args, validate=self._unsigned_int, **kwargs)
 
     @staticmethod
diff --git a/khal/utils.py b/khal/utils.py
index eda364a..b7e08ac 100644
--- a/khal/utils.py
+++ b/khal/utils.py
@@ -36,6 +36,7 @@ import pytz
 
 from khal.log import logger
 from khal.exceptions import FatalError
+from .khalendar.utils import sanitize
 
 
 def timefstr(dtime_list, timeformat):
@@ -599,7 +600,7 @@ def new_event(locale, dtstart=None, dtend=None, summary=None, timezone=None,
     return event
 
 
-def split_ics(ics, random_uid=False):
+def split_ics(ics, random_uid=False, default_timezone=None):
     """split an ics string into several according to VEVENT's UIDs
 
     and sort the right VTIMEZONEs accordingly
@@ -622,7 +623,7 @@ def split_ics(ics, random_uid=False):
             sorted(events_grouped.items())]
 
 
-def ics_from_list(events, tzs, random_uid=False):
+def ics_from_list(events, tzs, random_uid=False, default_timezone=None):
     """convert an iterable of icalendar.Events to an icalendar.Calendar
 
     :params events: list of events all with the same uid
@@ -641,6 +642,7 @@ def ics_from_list(events, tzs, random_uid=False):
 
     needed_tz, missing_tz = set(), set()
     for sub_event in events:
+        sub_event = sanitize(sub_event, default_timezone=default_timezone)
         if random_uid:
             sub_event['UID'] = new_uid
         # icalendar round-trip converts `TZID=a b` to `TZID="a b"` investigate, file bug XXX
@@ -736,3 +738,14 @@ def color_wrap(text, width=70):
             if num != len(lines):
                 lines[num + 1] = sgr + lines[num + 1]
     return lines
+
+
+def get_weekday_occurrence(day):
+    """Calculate how often this weekday has already occurred in a given month.
+
+    :type day: datetime.date
+    :returns: weekday (0=Monday, ..., 6=Sunday), occurrence
+    :rtype: tuple(int, int)
+    """
+    xthday = 1 + (day.day - 1) // 7
+    return day.weekday(), xthday
diff --git a/tox.ini b/tox.ini
index b853f35..b09439f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -25,11 +25,11 @@ commands =
     py.test --cov khal {posargs}
     codecov -e TOXENV
 
-[testenv:py-tests-negtz]
+[testenv:negtz]
 setenv =
     TZ = America/Argentina/Buenos_Aires
 
-[testenv:py-style]
+[testenv:style]
 skip_install=True
 whitelist_externals = sh
 deps = flake8
@@ -37,6 +37,13 @@ commands =
     flake8
     sh -c '! grep -ri seperat */*'
 
+[testenv:docs]
+whitelist_externals = make
+commands =
+  pip install -r doc/requirements.txt
+  make -C doc html
+  make -C doc man
+
 [flake8]
 max-line-length = 100
 exclude=.tox,examples,doc


Test patch:
diff --git a/tests/controller_test.py b/tests/controller_test.py
index b37c627..a90e781 100644
--- a/tests/controller_test.py
+++ b/tests/controller_test.py
@@ -16,7 +16,7 @@ today = dt.date.today()
 yesterday = today - dt.timedelta(days=1)
 tomorrow = today + dt.timedelta(days=1)
 
-event_allday_template = u"""BEGIN:VEVENT
+event_allday_template = """BEGIN:VEVENT
 SEQUENCE:0
 UID:uid3@host1.com
 DTSTART;VALUE=DATE:{}
@@ -38,7 +38,7 @@ conf = {'locale': utils.LOCALE_BERLIN,
         }
 
 
-class TestGetAgenda(object):
+class TestGetAgenda:
     def test_new_event(self, coll_vdirs):
         coll, vdirs = coll_vdirs
         event = coll.new_event(event_today, utils.cal1)
@@ -93,7 +93,7 @@ class TestGetAgenda(object):
             khal_list(coll, [], conf, agenda_format=event_format, day_format="{name}")).lower()
 
 
-class TestImport(object):
+class TestImport:
     def test_import(self, coll_vdirs):
         coll, vdirs = coll_vdirs
         view = {'event_format': '{title}'}
diff --git a/tests/khalendar_test.py b/tests/khalendar_test.py
index 76c6988..2387cd0 100644
--- a/tests/khalendar_test.py
+++ b/tests/khalendar_test.py
@@ -21,7 +21,7 @@ today = date.today()
 yesterday = today - timedelta(days=1)
 tomorrow = today + timedelta(days=1)
 
-event_allday_template = u"""BEGIN:VEVENT
+event_allday_template = """BEGIN:VEVENT
 SEQUENCE:0
 UID:uid3@host1.com
 DTSTART;VALUE=DATE:{}
diff --git a/tests/terminal_test.py b/tests/terminal_test.py
index c5aca23..ccc84c8 100644
--- a/tests/terminal_test.py
+++ b/tests/terminal_test.py
@@ -14,7 +14,7 @@ def test_colored():
         '\x1b[38;2;255;0;255m\x1b[48;2;0;170;187mtäst\x1b[0m'
 
 
-class TestMergeColumns(object):
+class TestMergeColumns:
 
     def test_longer_right(self):
         left = ['uiae', 'nrtd']
diff --git a/tests/utils_test.py b/tests/utils_test.py
index 296253e..974cf35 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -9,6 +9,7 @@ from freezegun import freeze_time
 
 from khal.utils import guessdatetimefstr, guesstimedeltafstr, new_event, eventinfofstr
 from khal.utils import timedelta2str, guessrangefstr, weekdaypstr, construct_daynames
+from khal.utils import get_weekday_occurrence
 from khal import utils
 from khal.exceptions import FatalError
 import pytest
@@ -88,7 +89,7 @@ def test_normalize_component():
     """))
 
 
-class TestGuessDatetimefstr(object):
+class TestGuessDatetimefstr:
     tomorrow16 = datetime.combine(tomorrow, time(16, 0))
 
     def test_today(self):
@@ -138,7 +139,7 @@ class TestGuessDatetimefstr(object):
             )
 
 
-class TestGuessTimedeltafstr(object):
+class TestGuessTimedeltafstr:
 
     def test_single(self):
         assert timedelta(minutes=10) == guesstimedeltafstr('10m')
@@ -170,7 +171,7 @@ class TestGuessTimedeltafstr(object):
             guesstimedeltafstr('10min 10minutes')
 
 
-class TestGuessRangefstr(object):
+class TestGuessRangefstr:
     td_1d = timedelta(days=1)
     today_start = datetime.combine(date.today(), time.min)
     tomorrow_start = today_start + td_1d
@@ -276,7 +277,7 @@ class TestGuessRangefstr(object):
                 guessrangefstr('2017-1-1 2017-1-1', locale=locale)
 
 
-class TestTimeDelta2Str(object):
+class TestTimeDelta2Str:
 
     def test_single(self):
         assert timedelta2str(timedelta(minutes=10)) == '10m'
@@ -670,3 +671,24 @@ def test_color_wrap_256():
     ]
 
     assert utils.color_wrap(text, 30) == expected
+
+
+def test_get_weekday_occurrence():
+    assert get_weekday_occurrence(datetime(2017, 3, 1)) == (2, 1)
+    assert get_weekday_occurrence(datetime(2017, 3, 2)) == (3, 1)
+    assert get_weekday_occurrence(datetime(2017, 3, 3)) == (4, 1)
+    assert get_weekday_occurrence(datetime(2017, 3, 4)) == (5, 1)
+    assert get_weekday_occurrence(datetime(2017, 3, 5)) == (6, 1)
+    assert get_weekday_occurrence(datetime(2017, 3, 6)) == (0, 1)
+    assert get_weekday_occurrence(datetime(2017, 3, 7)) == (1, 1)
+    assert get_weekday_occurrence(datetime(2017, 3, 8)) == (2, 2)
+    assert get_weekday_occurrence(datetime(2017, 3, 9)) == (3, 2)
+    assert get_weekday_occurrence(datetime(2017, 3, 10)) == (4, 2)
+
+    assert get_weekday_occurrence(datetime(2017, 3, 31)) == (4, 5)
+
+    assert get_weekday_occurrence(date(2017, 5, 1)) == (0, 1)
+    assert get_weekday_occurrence(date(2017, 5, 7)) == (6, 1)
+    assert get_weekday_occurrence(date(2017, 5, 8)) == (0, 2)
+    assert get_weekday_occurrence(date(2017, 5, 28)) == (6, 4)
+    assert get_weekday_occurrence(date(2017, 5, 29)) == (0, 5)