HOME/Articles/

matplotlib example timesheet (snippet)

Article Outline

Python matplotlib example 'timesheet'

Functions in program:

  • def run(login, options):
  • def collect_hours_daily(records, range_filter):
  • def parse_timesheet(lines):
  • def get_with_cache(url, timeout=None):
  • def debug(line):

Modules used in program:

  • import requests
  • import pandas as pd
  • import numpy as np
  • import optparse
  • import json
  • import math
  • import base64
  • import time
  • import os

python timesheet

Python matplotlib example: timesheet

# coding: utf-8
"""
Shows basic statistics from last month on your timesheet.
Uses median from last 60 records to try and predict how much hours you will work.
Script will not count vacations outside of Russia, so beware!
Alternatively, script can schedule for you how many hours a day you need to work from now on.

Also, script can plot some basic graphs for visualization.

TODO:
    - chart about tickets that had most hours spent

Install (on Ubuntu):
    apt-get install python-numpy python-matplotlib python-matplotlib-data python-pandas python-requests

Install (on Mac OS):
    pip install numpy matplotlib pandas requests python-dateutil==2.2

Usage: python timesheet.py [login] [--schedule] [--plot] [--money] [--month=<int>] [--year=<int>]

Options:
  -h, --help            show this help message and exit
  -m MONTH, --month=MONTH
                        month to show stats for
  -y YEAR, --year=YEAR  year to show stats for
  -p, --plot            plot charts with matplotlib
  --local               do not call external APIs
  --schedule            schedule how much you need to work instead of predict
  --no-overtime         not counting paid overtime
  --money               show predicted salary
  --test                run tests
  --debug               debug mode
"""
from __future__ import unicode_literals
from __future__ import print_function

import os
import time
import base64
import math
import json
import optparse
from itertools import groupby, ifilter, imap
from datetime import timedelta, date, datetime as dt
from collections import OrderedDict, namedtuple
from tempfile import gettempdir
import numpy as np
import pandas as pd
import requests

DEFAULT_LOGIN = 'nborisenko'
TIMESHEETS_DIR = '~/iponweb/admin/timesheet/'
ALLOWED_OVERTIME = 20  # paid monthly overtime hours
STANDARD_WORKDAY = 8  # hours in standard workday
BASE_SALARY = 0 * 1000

NOW = dt.now()  # redefine for manual testing
# NOW = dt(2015, 9, 23, 0, 5)
DAYS_FOR_STATS = 60
CACHE_EXPIRE = 2 * 24 * 60 * 60  # 2 days

parser = optparse.OptionParser(
        usage="python timesheet.py [login] [--schedule] [--plot] [--money] [--month=<int>] [--year=<int>]"
)
parser.add_option("-m", "--month", type="int",
                  default=NOW.month,
                  help="month to show stats for")
parser.add_option("-y", "--year", type="int",
                  default=NOW.year,
                  help="year to show stats for")
parser.add_option("-p", "--plot",
                  action="store_true", default=False,
                  help="plot charts with matplotlib")
parser.add_option("--local",
                  action="store_true", default=False,
                  help="do not call external APIs")
parser.add_option("--schedule",
                  action="store_true", default=False,
                  help="schedule how much you need to work from now on")
parser.add_option("--no-overtime", dest='overtime',
                  action="store_false", default=True,
                  help="not counting paid overtime")
parser.add_option("--money",
                  action="store_true", default=False,
                  help="show predicted salary")
parser.add_option("--test",
                  action="store_true", default=False,
                  help="run tests")
parser.add_option("--debug",
                  action="store_true", default=False,
                  help="debug mode")

DAY_TYPE = {
    'workfday': 0,
    'weekend': 1,
    'holiday': 2,
    'moved_workday': 3
}


def debug(line):
    if options.debug:
        newline = '\n' if line.startswith('\n') else ''
        print(newline + "DEBUG:", line.lstrip())


def get_with_cache(url, timeout=None):
    """
    First trying to get requested content from tempfile cache,
    then getting it from url
    """
    tmpdir_path = gettempdir()
    tmpfile_path = os.path.join(tmpdir_path, base64.urlsafe_b64encode(url))
    if not os.path.exists(tmpfile_path) or (time.time() - os.stat(tmpfile_path).st_ctime) > CACHE_EXPIRE:
        debug('requesting data from %s' % url)

        resp = requests.get(url, timeout=timeout)
        assert resp.status_code == 200, 'error response from "%s": %d' % (url, resp.status_code)
        data = resp.content

        with open(tmpfile_path, 'w') as tfile:
            debug('dumping data to %s' % tmpfile_path)
            tfile.write(data)
    else:
        debug('loading data from %s' % tmpfile_path)
        with open(tmpfile_path, 'r') as tfile:
            data = tfile.read()

    return data

# internal record representation for this script
Record = namedtuple('Record', ['fr', 'to', 'ticket'])


def parse_timesheet(lines):
    """
    Parse lines from timesheet to list of records with start datetime, end datetime and ticket id for each entry.
    Record example: 2015-11-03,17:43,17:54,uworkflow,RT:335951,"review"

    :type lines: list[str]
    :rtype: list[Record]

    >>> parse_timesheet([
    ...      "$version",
    ...      "2015-01-01,09:20,10:05,someq,RT:112",
    ...      "#sometag",
    ...      "2015-01-02,23:04,23:45,someq,RT:332442",
    ...      "#2015-01-02,23:04,23:45,commented,RT:332442",
    ...      "2015-01-02,23:04,23:45,someq,RT:333111",
    ...      "# EOM"
    ... ])
    [Record(fr=Timestamp('2015-01-01 09:20:00'), to=Timestamp('2015-01-01 10:05:00'), ticket=112), \
     Record(fr=Timestamp('2015-01-02 23:04:00'), to=Timestamp('2015-01-02 23:45:00'), ticket=332442), \
     Record(fr=Timestamp('2015-01-02 23:04:00'), to=Timestamp('2015-01-02 23:45:00'), ticket=333111)]
    """

    def to_format(line):
        result = None
        try:
            result = pd.Timestamp(dt.strptime(line, '%Y-%m-%d %H:%M'))
        except ValueError:
            pass
        return result

    lines = ifilter(lambda line: line.strip(), lines)
    data = imap(lambda line: line.strip().split(',')[:5], lines)

    raw_records = ifilter(lambda elem: len(elem) == 5 and not elem[0].startswith('#'), data)
    raw_records = imap(lambda (date, time1, time2, queue, t_id): (date + ' ' + time1, date + ' ' + time2, t_id),
                       raw_records)

    return filter(lambda record: record.fr and record.to,
                  map(lambda (fr, to, t_id): Record(to_format(fr), to_format(to), int(t_id[3:])), raw_records))


def collect_hours_daily(records, range_filter):
    """
    Group records filtered through {range_filter} function by start date and
    calculate worked hours per day

    :type records: list[Record]
    :type range_filter: callable
    :rtype: dict{date: float}

    >>> collect_hours_daily([
    ...     Record(dt(2015, 1, 1, 9, 20), dt(2015, 1, 1, 10, 5), 11),
    ...     Record(dt(2015, 1, 1, 12, 17),dt(2015, 1, 1, 12, 20), 2233),
    ...     Record(dt(2015, 1, 2, 23, 4), dt(2015, 1, 2, 23, 45), 3344556)
    ... ], bool)
    [(datetime.date(2015, 1, 1), 0.8), (datetime.date(2015, 1, 2), 0.68)]
    """
    records_in_range = filter(range_filter, records)

    if not records_in_range:
        raise ValueError('no records for selected range')

    date_hours = map(lambda (k, e): (k, round(sum(map(lambda rec: (rec.to - rec.fr).seconds, e)) / 3600.0, 2)),
                     (groupby(records_in_range, lambda elem: elem[0].date())))
    return date_hours


class MonthTimeframe(object):
    """
    Class to represent selected month timeframe.
    Allows to get working days, weekends, holidays for the selected month.
    Tries to get state holidays calendar from `calendar_url` if we're in Russia atm.
    """
    geo_url = 'http://ip-api.com/json'
    calendar_url = 'http://basicdata.ru/api/json/calend/'

    def __init__(self, month, year, local=False):
        """
        :type month: int
        :type year: int
        :type local: bool
        """
        self.month = month
        self.year = year
        self.local = local
        self._cached_calendar = None

    @property
    def month_bounds(self):
        """
        :rtype: tuple(date, date)

        >>> MonthTimeframe(1, 2014, local=True).month_bounds
        (datetime.date(2014, 1, 1), datetime.date(2014, 1, 31))
        >>> MonthTimeframe(9, 2015, local=True).month_bounds
        (datetime.date(2015, 9, 1), datetime.date(2015, 9, 30))
        >>> MonthTimeframe(12, 2015, local=True).month_bounds
        (datetime.date(2015, 12, 1), datetime.date(2015, 12, 31))
        """
        month_first = date(year=self.year, month=self.month, day=1)

        if self.month == 12:
            month_last = month_first.replace(day=31)
        else:
            month_last = month_first.replace(month=self.month + 1, day=1) - timedelta(days=1)

        return month_first, month_last

    @property
    def state_holidays_calendar(self):
        """
        If we are in Russia, get state holidays and moved working days for selected month.
        First check our country by user IP, then get work days and holidays for month from webservice.

        :rtype: dict{Timestamp: int}
        :return: dict{<pandas day>: <day working status>}

        >>> MonthTimeframe(month=1, year=2016, local=True).state_holidays_calendar
        {}
        """
        results = {}

        if self.local:
            debug('\nlocal mode: skipping attempt to obtain holidays info')
            return results

        try:
            debug('\jen'
                  'trying to infer country by ip via "%s"' % self.geo_url)
            country = json.loads(get_with_cache(self.geo_url, timeout=10))

            # holidays are used only in Russia
            if country["countryCode"] == "RU":
                debug('we\'re in Russia! Trying to get month holidays from "%s"' % self.calendar_url)
                calendar = json.loads(get_with_cache(self.calendar_url, timeout=10))

                month_special_days = calendar["data"][str(self.year)].get(str(self.month), {})
                results = {pd_date: month_special_days[str(pd_date.day)]["isWorking"]
                           for pd_date in pd.date_range(*self.month_bounds) if str(pd_date.day) in month_special_days}

                debug('holidays are {0}\n'.format(results) if results
                      else 'no holidays in {0}-{1}\n'.format(self.year, self.month))

        except (requests.RequestException, ValueError, AssertionError) as exc:
            # no sugar
            print("Can't get information on holidays, will try to predict without it.")
            if options.debug:
                raise exc

        return results

    @property
    def month_work_calendar(self):
        """
        Return ordered dict with days and their working status for this month.
        If we're in Russia, try to account for state holidays too.

        :rtype: OrderedDict{Timestamp: int}

        >>> MonthTimeframe(month=1, year=2016, local=True).month_work_calendar.items()[:2]
        [(Timestamp('2016-01-01 00:00:00', offset='D'), 0), (Timestamp('2016-01-02 00:00:00', offset='D'), 2)]
        """
        if self._cached_calendar is not None:
            return self._cached_calendar

        calendar = OrderedDict([
           (pd_date, DAY_TYPE['workfday'] if pd_date.weekday() < 5 else DAY_TYPE['holiday'])
           for pd_date in pd.date_range(*self.month_bounds)
        ])

        if not self.local:
            calendar.update(self.state_holidays_calendar)

        self._cached_calendar = calendar
        return self._cached_calendar

    @property
    def holidays(self):
        return [day for day, day_type in self.month_work_calendar.items() if day_type == DAY_TYPE['holiday']]

    @property
    def weekends(self):
        return [day for day, day_type in self.month_work_calendar.items() if day_type == DAY_TYPE['weekend']]

    @property
    def workdays(self):
        return [day for day, day_type in self.month_work_calendar.items()
                if day_type in {DAY_TYPE['workfday'], DAY_TYPE['moved_workday']}]

    @property
    def nonworkdays(self):
        return self.holidays + self.weekends


class WorkLog(object):
    """
    >>> timeframe = MonthTimeframe(month=1, year=2016, local=True)
    >>> log = WorkLog(timeframe)
    >>> lines = [
    ...     "some version bullshit",
    ...     "2015-12-28,11:00,12:22,sunday,RT:11", "2014-12-30,12:40,13:00,weekday,RT:11",
    ...     "2015-12-31,09:00,12:00,weekday,RT:11", "2015-01-04,12:00,16:59,sunday,RT:11",
    ...     "2016-01-03,11:00,12:22,sunday,RT:11", "2014-12-30,12:40,13:00,weekday,RT:11",
    ...     "2016-01-04,09:00,12:00,monday,RT:11", "2015-01-04,12:00,16:59,sunday,RT:11",
    ...     "2016-01-05,12:00,18:05,tuesday,RT:11", "2015-01-06,11:00,18:40,weekday,RT:11"
    ... ]
    >>> records = parse_timesheet(lines)
    >>> log.predict(records)
    >>> log.display_results(with_salary=True)
    Total 10 hours 24 minutes in 5 days
    Assumed salary is 610 units (base salary is 9,999 units)
    Details:
        Date            Hours
         2016-01-03, Sunday      1 h. 22 m.
         2016-01-04, Monday      3 h. 0 m.
         2016-01-05, Tuesday      6 h. 4 m.
    <BLANKLINE>
    January 2016 has 168 standard working hours
    <BLANKLINE>
    >>> log.month_total_hours  # 2 holidays are on weekend so only -8
    168
    >>> log.future_weekday_hours
    4.54
    >>> log.future_weekend_hours
    0.68500000000000005
    """
    future_weekday_hours = 0
    future_weekend_hours = 0

    worked_days = OrderedDict()
    hours_worked = 0
    days_to_come = OrderedDict()
    hours_predicted = 0

    last_recorded_date = None

    mode_templates = {
        'predictions': {
            'total': "\nPredicted total {0} hours from standard {1} in {2} days",
            'salary': "Predicted salary is {0:,} units ({2}base salary is {1:,} units)",
            'details header': "Prognosis:\n\tDate\t\t\tHours",
            'chart title': "Month statistics and predictions",
            'line label': "predicted time",
            'hours hline label': "predicted\nhours"
        },
        'scheduling': {
            'total': "\nScheduled total {0} hours from standard {1} in {2} days",
            'salary': "Scheduled salary is {0:,} units ({2}base salary is {1:,} units)",
            'details header': "Schedule:\n\tDate\t\t\tHours",
            'chart title': "Month statistics and schedule",
            'line label': "scheduled time",
            'hours hline label': "scheduled\nhours"
        }
    }
    template = None

    def __init__(self, timeframe, with_overtime=True):
        """
        :type timeframe: MonthTimeframe
        :type with_overtime: bool
        """
        self.timeframe = timeframe
        self.month = timeframe.month
        self.year = timeframe.year

        self.month_first, self.month_last = timeframe.month_bounds
        self.month_range = pd.date_range(*timeframe.month_bounds)

        # collect goal with respect to the holidays
        self.month_total_hours = STANDARD_WORKDAY * len(timeframe.workdays)
        self.goal = self.month_total_hours + ALLOWED_OVERTIME if with_overtime else self.month_total_hours

    def _calculate_month_work(self, records):
        """
        Parse records from timesheet and populate internal data: medians, last month and hours worked struct
        """
        month_filter = lambda record: (record.fr.date().month, record.fr.date().year) == (self.month, self.year)
        month_hours = collect_hours_daily(records, month_filter)

        self.worked_days = OrderedDict(month_hours)
        self.hours_worked = round(sum(self.worked_days.values()), 1)

        self.last_recorded_date = month_hours[-1][0]
        debug('last_recorded_date: %s\n' % self.last_recorded_date)

    def _calculate_salary(self, hours):
        """
        Calculate approximated salary from worked hours
        with respect to amount of paid overtime

        :type hours: float
        :rtype: tuple(int, bool)
        """
        # rounding salary up depending on the base salary amount
        rounding_to = 10 ** (int(math.log10(BASE_SALARY or 1)) - 2)
        trimmed = False

        paid_hours = hours
        if hours > self.goal:
            paid_hours = self.goal
            trimmed = True

        money = int(BASE_SALARY * paid_hours / self.month_total_hours / rounding_to) * rounding_to
        return money, trimmed

    def _predict_hours(self, records):
        """
        Calculate medians (separate for weekdays and weekends) from record period determined by {DAYS_FOR_STATS}
        """
        stats_days_range = pd.date_range(self.last_recorded_date - timedelta(days=DAYS_FOR_STATS),
                                         self.last_recorded_date)
        stats_days_filter = lambda record: (record.fr.date() in stats_days_range)
        stats_days = OrderedDict(collect_hours_daily(records, stats_days_filter))

        # weekday median is calculated across last {DAYS_FOR_STATS} days
        self.future_weekday_hours = np.median(np.array([
            hours for day, hours in stats_days.items() if pd.Timestamp(day) in self.timeframe.workdays
        ]))

        # weekend median is calculated across selected month nonworking days
        self.future_weekend_hours = np.median(np.array([
            stats_days.get(pd_day.date(), 0) for pd_day in stats_days_range if pd_day in self.timeframe.nonworkdays
        ]))

    def _predict_future(self):
        """
        Fill future days with data on how many hours you will be working
        """
        for pd_date in filter(lambda pd_date: pd_date.date() >= self.predict_from, self.month_range):
            # try to fill hours with regards to weekends and holidays
            if pd_date in self.timeframe.workdays:
                day_hours = self.future_weekday_hours
            else:
                day_hours = self.future_weekend_hours

            if day_hours:
                self.hours_predicted += day_hours
                self.days_to_come[pd_date.date()] = day_hours

    @property
    def predict_from(self):
        """
        Return day from which prediction will take place
        :rtype: date
        """
        today_date = NOW.date()
        return self.last_recorded_date + timedelta(days=1) if self.last_recorded_date == today_date else today_date

    def predict(self, records):
        """
        Try to predict working hours for the rest of the month using median from last 30 days records
        """
        self._calculate_month_work(records)
        self._predict_hours(records)

        self.hours_predicted = 0

        debug('\ntrying to predict your future working hours...')
        debug('weekday median: %.1f' % self.future_weekday_hours)
        debug('weekend median: %.1f' % self.future_weekend_hours)

        self.template = self.mode_templates['predictions']

        self._predict_future()

    def schedule(self, records):
        """
        Calculate how much you need to be working every day from now on to precisely reach the goal
        """
        if (self.month, self.year) != (NOW.month, NOW.year):
            raise ValueError

        self._calculate_month_work(records)

        available_days = filter(lambda pd_date: pd_date.date() >= self.predict_from,
                                self.timeframe.workdays)

        needed_hours = self.goal - self.hours_worked
        self.future_weekend_hours = 0

        if available_days:
            self.future_weekday_hours = needed_hours / len(available_days)
        else:
            self.future_weekday_hours = 0

        debug('\nscheduling {0} hours for the next {1} work days'.format(needed_hours, len(available_days)))
        debug('sheduled hours per day: %.1f' % self.future_weekday_hours)

        self.template = self.mode_templates['scheduling']

        self._predict_future()

    def print_past(self, with_salary=False):
        print("Total %d hours %d minutes in %d days" % (
            int(self.hours_worked), int(self.hours_worked % 1 * 60), self.last_recorded_date.day
        ))
        if with_salary and not self.days_to_come:
            assumed_salary, trimmed = self._calculate_salary(self.hours_worked)
            trim_message = 'trimmed by allowed overtime, ' if trimmed else ''
            print("Assumed salary is {0:,} units ({2}base salary is {1:,} units)".format(assumed_salary,
                                                                                         BASE_SALARY,
                                                                                         trim_message))

        print("Details:\n\tDate\t\t\tHours")

        for day, hours in self.worked_days.items():
            print("\t", day.strftime("%Y-%m-%d, %A"), "\t", "%d h. %d m." % (int(hours), int(hours % 1 * 60)))

        if not self.days_to_come:
            print("\n%s has %d standard working hours\n" %
                  (self.month_first.strftime("%B %Y"), self.month_total_hours))

    def print_future(self, with_salary=False):
        if not self.days_to_come:
            debug('nothing to predict/schedule from %s' % repr(self.predict_from))
            return

        hours_in_month = int(self.hours_worked + self.hours_predicted)
        print(self.template['total'].format(hours_in_month, self.month_total_hours, self.month_last.day))

        if with_salary:
            assumed_salary, trimmed = self._calculate_salary(hours_in_month)
            trim_message = 'trimmed by allowed overtime, ' if trimmed else ''

            print(self.template['salary'].format(assumed_salary, BASE_SALARY, trim_message))

        print(self.template['details header'])
        for day, hours in self.days_to_come.items():
            print("\t", day.strftime("%Y-%m-%d, %A"), "\t", "%d h. %d m." % (int(hours), int(hours % 1 * 60)))

    def plot_charts(self, with_salary=False):
        """
        Plot 2 charts:
            1. line chart with worked time this month + prediction / scheduled
            2. bar chart with worked hour per day data

        :type with_salary: bool
        :param with_salary: flag if month statistics chart should show assumed salary instead of hours
        """
        import matplotlib
        import matplotlib.pyplot as plt

        # check if we're working under old matplotlib from Ubuntu repo
        _old_matplotlib = (float(matplotlib.__version__.rsplit('.', 1)[0]) < 1.4)

        if _old_matplotlib:
            pd.options.display.mpl_style = 'default'
        else:
            plt.style.use('ggplot')

        self.plot_month_statistics_chart(plt, _old_matplotlib, with_salary)
        self.plot_hours_per_day(plt, _old_matplotlib)

        # show both charts
        if _old_matplotlib:
            plt.show(block=True)
        else:
            plt.show()

    def plot_month_statistics_chart(self, plt, old_matplotlib=False, with_salary=False):
        """
        Month statictics chart

        :type plt: 'pyplot.py'
        :param plt: matplotlib.pyplot module - it's stateful so better to send it in
                    every function where it's needed explicitly
        :type old_matplotlib: bool
        :param old_matplotlib: flag if code is working under matplotlib < 1.4
        :type with_salary: bool
        :param with_salary: flag if chart should show assumed salary instead of hours
        """
        acc_worked = OrderedDict()
        acc_predicted = OrderedDict()
        worked_accumulator = 0
        predicted_accumulator = 0

        debug('\nplotting prediction from %s' % repr(self.predict_from))

        # collecting data for chart
        for pd_date in self.month_range:

            day_worked = self.worked_days.get(pd_date.date(), 0)
            if pd_date.date() < self.predict_from:
                worked_accumulator += day_worked
                acc_worked[pd_date.day] = round(worked_accumulator, 2)

            if pd_date.date() == self.predict_from:
                # we need to start "predicted" plot from the last point of "worked" plot
                predicted_accumulator = worked_accumulator
                acc_predicted[acc_worked.keys()[-1]] = round(predicted_accumulator, 2)

            if pd_date.date() >= self.predict_from:
                predicted_accumulator += self.days_to_come.get(pd_date.date(), 0)
                acc_predicted[pd_date.day] = round(predicted_accumulator, 2)

        plt.figure(self.template['chart title'], figsize=(12, 6))
        line1, = plt.plot(acc_worked.keys(), acc_worked.values(), color="cornflowerblue", label="worked time")
        line2, = plt.plot(acc_predicted.keys(), acc_predicted.values(),
                          color="indianred", linestyle='--', label=self.template['line label'])

        if not old_matplotlib:
            plt.legend(handles=[line1, line2], loc=2)

        plt.xticks(np.arange(1, self.month_last.day, 1))

        plt.title(self.last_recorded_date.strftime("%B, %Y"))

        max_hours = acc_predicted.values()[-1] if acc_predicted else acc_worked.values()[-1]
        paid_overtime = self.month_total_hours + ALLOWED_OVERTIME

        plt.axis([1, self.month_last.day, 0, max(paid_overtime, max_hours) + 10])
        plt.ylabel('hours')
        plt.xlabel('day of the month')

        if self.month == NOW.month:
            plt.axvline(x=NOW.day, color='darkseagreen')

        plt.axhline(y=self.month_total_hours, color='olivedrab', linestyle='--')
        plt.text(x=14, y=self.month_total_hours + 2, s="standard work hours", color='olivedrab')
        plt.axhline(y=paid_overtime, color='mediumpurple', linestyle='--')
        plt.text(x=14.7, y=paid_overtime + 2, s="paid overtime", color='mediumpurple')

        # side labels on month statictic chart
        side_text_predicted = '  {0} hours'.format(int(max_hours))
        side_text_overtime = '  {0} hours'.format(paid_overtime)
        trimmed = False
        if with_salary:
            predicted_salary, trimmed = self._calculate_salary(max_hours)

            side_text_predicted = '  {0:,} units'.format(predicted_salary)
            # base pay + all possible pay for overtime, not rounded - we can be sure it's a right amount
            side_text_overtime = '  {0:,} units'.format(int(BASE_SALARY * (paid_overtime) / self.month_total_hours))

        if not (with_salary and trimmed):
            # no point to show more money than we can have
            plt.text(x=self.month_last.day, y=max_hours - 1, s=side_text_predicted,
                     color="indianred" if acc_predicted else "cornflowerblue", fontdict={'size': 12})

        if abs(paid_overtime - max_hours) > 5 or (with_salary and trimmed):
            # to not place one label atop of another
            plt.text(x=self.month_last.day, y=paid_overtime - 1, s=side_text_overtime,
                     color="mediumpurple", fontdict={'size': 12})

    def plot_hours_per_day(self, plt, old_matplotlib=False):
        """
        Hours worked per day chart

        :type plt: 'pyplot.py'
        :param plt: pyplot module - it's stateful so better to send it in every function where it's needed explicitly
        :type old_matplotlib: bool
        :param old_matplotlib: flag if code is working under matplotlib < 1.4
        """
        padded_worked = OrderedDict()

        # collecting data for chart
        for pd_date in self.month_range:
            day_worked = self.worked_days.get(pd_date.date(), 0)
            padded_worked[pd_date.day] = day_worked

        df2 = pd.DataFrame({'Worked': padded_worked.values()}, index=[day.day for day in self.month_range])
        df2.plot(legend=False, figsize=(10, 4), kind='bar',
                 title=self.last_recorded_date.strftime("%B, %Y"), color="cornflowerblue")
        plt.gcf().canvas.set_window_title('Hours worked daily')
        plt.ylabel('hours')
        plt.xlabel('day of the month')

        plt.axhline(y=self.future_weekday_hours, color="indianred", linestyle="--")

        if self.month == NOW.month:
            plt.axvline(x=NOW.day - 1, color='darkseagreen')

        plt.text(x=self.month_last.day, y=self.future_weekday_hours - 0.5,
                 s=self.template['hours hline label'], color="indianred", fontdict={'size': 12})

    def display_results(self, plot=False, with_salary=False):
        self.print_past(with_salary)
        self.print_future(with_salary)

        if plot:
            self.plot_charts(with_salary)


def run(login, options):
    timesheets_path = os.path.abspath(os.path.expanduser(TIMESHEETS_DIR))
    with open(os.path.join(timesheets_path, login)) as datafile:
        lines = datafile.readlines()

    timeframe = MonthTimeframe(options.month, year=options.year, local=options.local)

    records = parse_timesheet(lines)
    worklog = WorkLog(timeframe=timeframe, with_overtime=options.overtime)

    try:
        if options.schedule:
            worklog.schedule(records)
        else:
            worklog.predict(records)

        worklog.display_results(plot=options.plot, with_salary=options.money)

    except ValueError as exc:
        print("sorry, nothing to show for selected month")
        debug(repr(exc))
        return


if __name__ == '__main__':
    (options, args) = parser.parse_args()

    user_login = DEFAULT_LOGIN
    if args:
        user_login = args[0]

    if options.test and options.debug:
        print("sorry, can't do that")

    if options.schedule and (options.month, options.year) != (NOW.month, NOW.year):
        print("sorry, can't schedule for the past")

    elif options.test:
        import doctest

        test_globals = globals()
        test_globals.update({'BASE_SALARY': 9999, 'DEFAULT_LOGIN': 'Skitter'})
        results = doctest.testmod(optionflags=doctest.NORMALIZE_WHITESPACE, globs=test_globals)
        if not results.failed:
            print("All tests are green")

    else:
        run(user_login, options)