Return to Blog

Building a command line tool to visualize dates

By John Lekberg on February 05, 2020.


This week's post will cover a command line tool that visualizes dates. I'm interested in this for two reasons:

I wrote a Python script date-viz that takes a list of dates and visualizes them in a calendar view.

Script source code

date-viz

#!/usr/bin/env python3
"""
Visualize dates by marking them on a calendar.
"""

from calendar import Calendar
from collections import namedtuple
from itertools import groupby
from operator import attrgetter
import datetime
import sys


MonthData = namedtuple("MonthData", ["year", "month", "grid"])
MonthData.__doc__ = """\
    A month of data with dates in a grid.
"""


def iso_week(date):
    """The ISO calendar week for `date`."""
    _, week, _ = date.isocalendar()
    return week


def dates_to_month_data(dates):
    """Generate MonthData objects from `dates`."""
    start_ym = min((d.year, d.month) for d in dates)
    end_ym = max((d.year, d.month) for d in dates)

    year, month = start_ym
    while (year, month) <= end_ym:
        month_dates = Calendar().itermonthdates(year, month)
        grid = [
            [
                date
                for date in week_dates
            ]
            for _, week_dates in groupby(month_dates, iso_week)
        ]
        yield MonthData(year=year, month=month, grid=grid)
        year, month = year, month + 1
        if month > 12:
            year, month = year + 1, 1


def parse_dates(lines):
    """Parse dates from `lines`, strings in YYYY-MM-DD format."""
    strptime = datetime.datetime.strptime
    for line in lines:
        line = line.strip()
        if line:
            yield strptime(line, "%Y-%m-%d").date()


def render_month_data(
    month_data, *, fmt_month, fmt_present, fmt_absent, dates
):
    """Render a MonthData object as a printable string."""
    def format_cell(cell):
        same_year = cell.year == month_data.year
        same_month = cell.month == month_data.month
        if not (same_year and same_month):
            return ""
        elif cell in dates:
            return format(cell, fmt_present)
        else:
            return format(cell, fmt_absent)

    table = [
        [
            format_cell(date)
            for date in row
        ]
        for row in month_data.grid
    ]
    cell_width = max(
        len(cell)
        for row in table
        for cell in row
    )

    first_of_month = datetime.date(
        month_data.year, month_data.month, 1
    )

    header = format(first_of_month, fmt_month)
    table_str = "\n".join(
        " ".join(
            cell.ljust(cell_width)
            for cell in row
        )
        for row in table
    )

    return "\n".join([header, "", table_str, ""])
    

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--month", default="%B %Y",
        help="strftime format for month names (default \"%%B %%Y\")"
    )
    parser.add_argument(
        "--present", default="#",
        help="strftime format for marked days (default \"#\")"
    )
    parser.add_argument(
        "--absent", default=".",
        help="strftime format for unmarked days (default \".\")"
    )
    args = parser.parse_args()

    dates = set(parse_dates(sys.stdin))

    for month_data in dates_to_month_data(dates):
        print(
            render_month_data(
                month_data,
                fmt_month=args.month,
                fmt_present=args.present,
                fmt_absent=args.absent,
                dates=dates,
            )
        )
$ date-viz --help
usage: date-viz [-h] [--month MONTH] [--present PRESENT]
           [--absent ABSENT]

optional arguments:
  -h, --help         show this help message and exit
  --month MONTH      strftime format for month names (default "%B %Y")
  --present PRESENT  strftime format for marked days (default "#")
  --absent ABSENT    strftime format for unmarked days (default ".")

Using the script to track physical activity

I want to see how well I am doing following the CDC's activity guidelines. Here's a text file listing days in the past 2 months on which my activity met the guidelines:

activity.txt

2019-12-02
2019-12-03
2019-12-04
2019-12-05
2019-12-09
2019-12-10
2019-12-12
2019-12-16
2019-12-17
2019-12-18
2019-12-19
2019-12-20
2019-12-26
2019-12-27

2020-01-03
2020-01-06
2020-01-07
2020-01-08
2020-01-15
2020-01-16
2020-01-17
2020-01-20
2020-01-22
2020-01-23
2020-01-24
2020-01-27
2020-01-28
2020-01-29
2020-01-30

I use date-viz to show me how consistently I actually meet the guidelines:

$ date-viz <activity.txt
December 2019

            .
# # # # . . .
# # . # . . .
# # # # # . .
. . . # # . .
. .

January 2020

    . . # . .
# # # . . . .
. . # # # . .
# . # # # . .
# # # # .

Not bad, but I see that I'm missing the last two days of the week. Which days are these?

$ date-viz <activity.txt --absent '%a'
December 2019

                        Sun
#   #   #   #   Fri Sat Sun
#   #   Wed #   Fri Sat Sun
#   #   #   #   #   Sat Sun
Mon Tue Wed #   #   Sat Sun
Mon Tue                    

January 2020

        Wed Thu #   Sat Sun
#   #   #   Thu Fri Sat Sun
Mon Tue #   #   #   Sat Sun
#   Tue #   #   #   Sat Sun
#   #   #   #   Fri

From this, I see that

Now that I can see which days are lacking, I can deliberately put more effort into getting the required exercise in on those days.

Using the script to track headaches

I want to see if there is a pattern in which days I have headaches on. Here's a text file which lists days in the past 2 months with headaches:

headaches.txt

2019-12-07
2019-12-08
2019-12-09
2019-12-12
2019-12-14
2019-12-15
2019-12-16
2019-12-17
2019-12-21
2019-12-22
2019-12-23
2019-12-25
2019-12-28
2019-12-29
2019-12-30

2020-01-04
2020-01-05
2020-01-06
2020-01-09
2020-01-11
2020-01-12
2020-01-13
2020-01-15
2020-01-16
2020-01-18
2020-01-19
2020-01-20
2020-01-25
2020-01-26
2020-01-27

I use date-viz to see if there is any obvious pattern with this information:

$ date-viz <headaches.txt
December 2019

            .
. . . . . # #
# . . # . # #
# # . . . # #
# . # . . # #
# .          

January 2020

    . . . # #
# . . # . # #
# . # # . # #
# . . . . # #
# . . . .   

It looks like I consistently have headaches at the end of the week and at the beginning of the week. What days are these?

$ date-viz <headaches.txt --present '%a'
December 2019

                        .  
.   .   .   .   .   Sat Sun
Mon .   .   Thu .   Sat Sun
Mon Tue .   .   .   Sat Sun
Mon .   Wed .   .   Sat Sun
Mon .                      

January 2020

        .   .   .   Sat Sun
Mon .   .   Thu .   Sat Sun
Mon .   Wed Thu .   Sat Sun
Mon .   .   .   .   Sat Sun
Mon .   .   .   .          

I consistently get headaches on Saturday, Sunday, and Monday. Now that I know this, I can look over my journal and see if this is correlated with sleep pattern or certain foods that I'm eating.

How the script works

I use collections.namedtuple to hold months of information, keeping track of

I use datetime.date objects and I use Calender.itermonthdates to generate the dates for a given month, grouping them into a grid using itertools.groupby on the ISO week date (using my iso_week() function).

Dates are parsed and formatted using strptime and strftime, using time format codes, which include codes for

(There are a lot of other codes; read the documentation for details.)

To create a script that works on the command line, I use argparse to allow the user to supply custom format codes for

To build and modify the grids, I used nested list comprehensions because they handle nested lists nicely.

In conclusion...

This week's post covered a Python script that visualizes dates. You learned about

My challenge to you:

Figure out what time format codes to use to create this visualization of activity.txt (from above):

$ date-viz <activity.txt --present ? --absent ? --month ?
-- 12/19 --

                               01 
[02] [03] [04] [05]  06   07   08 
[09] [10]  11  [12]  13   14   15 
[16] [17] [18] [19] [20]  21   22 
 23   24   25  [26] [27]  28   29 
 30   31                          

-- 01/20 --

           01   02  [03]  04   05 
[06] [07] [08]  09   10   11   12 
 13   14  [15] [16] [17]  18   19 
[20]  21  [22] [23] [24]  25   26 
[27] [28] [29] [30]  31

(You'll need to read the time format codes documentation to solve this.)

If you enjoyed this week's post, share it with your friends and stay tuned for next week's post. See you then!