├── contributions ├── __init__.py ├── tests │ ├── __init__.py │ ├── file1.txt │ ├── file2.txt │ ├── file3.txt │ ├── parser.py │ └── dateutils.py ├── templates │ ├── index.html │ └── graph.html ├── statistics.py ├── parser.py ├── dateutils.py ├── static │ └── style.scss └── render_html.py ├── .gitignore ├── requirements.txt ├── screenshot-blue.png ├── screenshot-green.png ├── screenshot-red.png ├── example.txt ├── LICENSE └── README.md /contributions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /contributions/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /contributions/tests/file1.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .sass-cache/ 2 | contributions/static/style.css* 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Jinja2==2.7.3 2 | MarkupSafe==0.23 3 | wsgiref==0.1.2 4 | -------------------------------------------------------------------------------- /screenshot-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwlchan/contributions-graph/master/screenshot-blue.png -------------------------------------------------------------------------------- /screenshot-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwlchan/contributions-graph/master/screenshot-green.png -------------------------------------------------------------------------------- /screenshot-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwlchan/contributions-graph/master/screenshot-red.png -------------------------------------------------------------------------------- /contributions/tests/file2.txt: -------------------------------------------------------------------------------- 1 | # This is a comment 2 | 2012-10-09 123 3 | 4 | # This is a very simple file 5 | 2012-10-08 124 -------------------------------------------------------------------------------- /contributions/tests/file3.txt: -------------------------------------------------------------------------------- 1 | # This is anotjer comment 2 | 2012-10-09 123 3 | 4 | # This is a more complicated file 5 | 2012-10-08 124 6 | 2012-10-09 123 # This is the same as the line above: the result should be 7 | # 123*2 = 246, but that's what we have to test -------------------------------------------------------------------------------- /contributions/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 |{{ graph.repo_name }}Total contributions
50 |{{ graph.sum }}
51 |{{ start|display_date }} – {{ today|display_date }}
52 |Longest streak
55 | {% if graph.longest_streak %} 56 |{{ graph.longest_streak|length }} day{% if graph.longest_streak|length != 1 %}s{% endif %}
57 |{{ graph.longest_streak[0]|display_date }} – {{ graph.longest_streak[-1]|display_date }}
58 | {% else %} 59 |0 days
60 |No recent contributions
61 | {% endif %} 62 |Current streak
65 | {% if graph.current_streak %} 66 |{{ graph.current_streak|length }} day{% if graph.current_streak|length != 1 %}s{% endif %}
67 |{{ graph.current_streak[0]|display_date }} – {{ graph.current_streak[-1]|display_date }}
68 | {% else %} 69 |0 days
70 | {% if graph.last_date %} 71 |Last contributed {{ graph.last_date|elapsed_time }}
72 | {% else %} 73 |No recent contributions
74 | {% endif %} 75 | {% endif %} 76 |
8 |
9 | I wrote it so that I could use the GitHub design for other things I want to track, including exercise and reading.
10 |
11 | The original graph was [introduced by GitHub in 2013](https://github.com/blog/1360-introducing-contributions). I don't know whose idea it was; I just copied the design and built my own version of it.
12 |
13 | ## Installation
14 |
15 | Clone this repository onto your computer:
16 |
17 | ```none
18 | git clone git@github.com:alexwlchan/contributions-graph.git
19 | cd contributions-graph
20 | ```
21 |
22 | Install the Python dependencies (I recommend doing this inside a [virtualenv](https://pypi.python.org/pypi/virtualenv)):
23 |
24 | ```none
25 | pip install -r requirements.txt
26 | ```
27 |
28 | You also need to build the style sheets. This [uses Sass](http://sass-lang.com):
29 |
30 | ```none
31 | sass --scss contributions/static/style.scss:contributions/static/style.css
32 | ```
33 |
34 | If you don't want to or can't install Sass, you can also use an online converter, such as [Sassmeister](http://sassmeister.com).
35 |
36 | It should run on Python 2 or 3, although I've only tested it on 2.7.
37 |
38 | ## Usage
39 |
40 | Create a text file that records each day, with the number of contributions for that day, with a space between the date and the value:
41 |
42 | YYYY-MM-DD value
43 |
44 | Other notes:
45 |
46 | * As with Python source code, anything after a `#` is ignored and treated as a comment.
47 | * One date/value pair per line.
48 | * Blank lines are fine.
49 |
50 | I've included an example file in the repo: `example.txt`.
51 |
52 | Now use the `create_graph()` function, supplying the name of this text file, and you get the HTML for a simple page with the contributions graph:
53 |
54 | ```python
55 | from contributions.render_html import create_graph
56 | print create_graph("example.txt")
57 | ```
58 |
59 | If you have multiple such files, supplying them as a list to this function will put all the graphs on the same page:
60 |
61 | ```python
62 | from contributions.render_html import create_graph
63 | print create_graph(["example1.txt", "example2.txt"])
64 | ```
65 |
66 | This should work on Python 2 and 3, but I've only tested it in Python 2.7.
67 |
68 | ## Todo list
69 |
70 | Here are some ideas I have for the future:
71 |
72 | * More colours and shapes. Since each cell is just a `
77 |
78 |
79 |
80 | It would be nice for those to be available as options rather than by hand-tweaking.
81 |
82 | * A mobile version. The short and wide version doesn't really work on small screens, but I think this design *might* work if you rotated through 90 degrees. Weeks along the top, months down the side.
83 |
84 | I want to give that a go.
85 |
86 | * Unit tests are awesome. I should write more of them.
87 |
88 | * The ability to customise some of the text. Right now, it only says "Contributions". It would be nice to be able to put other words in as appropriate.
89 |
90 | * More statistics options. I just took the three stats that GitHub offers, but there may be different ones that are useful.
91 |
92 | (For example, a graph of steps walked doesn't really have much use for longest/current streak, but might want average daily steps.)
93 |
94 | * Skippable weekends? I think it might be useful to use this for some work-related tasks, but since I don't work weekends, there would be a bunch of blank boxes. It might be nice to have an option for omitting weekends.
95 |
96 | * Squash the bugs! Since this project involves a lot of fiddly stuff with calendars and dates, it's almost certain that somewhere, someday, something will go wrong. I'd like to do some more testing to find out if/where that's going to be.
--------------------------------------------------------------------------------
/contributions/render_html.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """
3 | The 'templates' directory contains two Jinja2 templates for rendering the
4 | graph:
5 |
6 | * `index.html` - the skeleton which only loads the CSS files, and then includes
7 | the output of the second template:
8 | * `graph.html` - this is the template which actually renders a graph.
9 |
10 | This module is responsible for preparing and rendering the templates.
11 | """
12 |
13 | from collections import namedtuple
14 | import datetime
15 | from types import StringTypes
16 | import ntpath
17 |
18 | from jinja2 import Environment, PackageLoader
19 |
20 | import contributions.dateutils as dateutils
21 | import contributions.parser as parser
22 | import contributions.statistics as statistics
23 |
24 | GridCell = namedtuple('GridCell', ['date', 'contributions'])
25 |
26 |
27 | def create_graph(filepaths):
28 | """
29 | Prepare the `index.html` template.
30 | """
31 | graphs = []
32 | if isinstance(filepaths, StringTypes):
33 | filepaths = [filepaths]
34 |
35 | for path in filepaths:
36 | contributions = parser.parse_file(path)
37 |
38 | graph = {
39 | "data": gridify_contributions(contributions),
40 | "cell_class": _cell_class(contributions.values()),
41 | "longest_streak": statistics.longest_streak(
42 | [key for key, val in contributions.iteritems() if val > 0]
43 | ),
44 | "current_streak": statistics.current_streak(
45 | [key for key, val in contributions.iteritems() if val > 0]
46 | ),
47 | "sum": sum(contributions.itervalues()),
48 | "repo_name": ntpath.basename(path)
49 | }
50 |
51 | graph["last_date"] = (
52 | [""] + sorted([key for key, v in contributions.iteritems() if v])
53 | )[-1]
54 |
55 | graphs.append(graph)
56 |
57 | env = Environment(loader=PackageLoader('contributions', 'templates'))
58 |
59 | env.filters['tooltip'] = tooltip_text
60 | env.filters['display_date'] = dateutils.display_date
61 | env.filters['elapsed_time'] = dateutils.elapsed_time
62 |
63 | template = env.get_template("index.html")
64 |
65 | weekdays = dateutils.weekday_initials()
66 | for idx in [0, 2, 4, 6]:
67 | weekdays[idx] = ""
68 |
69 | months = [
70 | cell.date.strftime("%b")
71 | for cell in gridify_contributions(contributions)[0]
72 | ]
73 | months = filter_months(months)
74 |
75 | return template.render(graphs=graphs,
76 | today=dateutils.today(),
77 | start=dateutils.start(),
78 | weekdays=weekdays,
79 | months=months)
80 |
81 |
82 | def gridify_contributions(contributions):
83 | """
84 | The contributions graph has seven rows (one for each day of the week).
85 | It spans a year. Given a dict of date/value pairs, rearrange these results
86 | into seven rows of "cells", where each cell records a date and a value.
87 | """
88 | start = dateutils.start()
89 | today = dateutils.today()
90 |
91 | graph_entries = []
92 |
93 | # The first row is a Sunday, so go back to the last Sunday before the start
94 | if start.weekday() == 6:
95 | first_date = start
96 | else:
97 | first_date = start - datetime.timedelta(start.weekday() + 1 % 7)
98 | next_date = first_date
99 |
100 | first_row_dates = [first_date]
101 | while (next_date <= today) and (next_date + datetime.timedelta(7) <= today):
102 | next_date += datetime.timedelta(7)
103 | first_row_dates.append(next_date)
104 |
105 | # Now get contribution counts for each of these dates, and save the row
106 | first_row = [
107 | GridCell(date, contributions[date]) for date in first_row_dates
108 | ]
109 | graph_entries.append(first_row)
110 |
111 | # For each subsequent day of the week, use the first row as a model: add
112 | # the appropriate number of days and count the contributions
113 | for i in range(1, 7):
114 | row_dates = [day + datetime.timedelta(i) for day in first_row_dates]
115 | next_row = [
116 | GridCell(date, contributions[date]) for date in row_dates
117 | ]
118 | graph_entries.append(next_row)
119 |
120 | return graph_entries
121 |
122 |
123 | def tooltip_text(cell):
124 | """
125 | Returns the tooltip text for a cell.
126 | """
127 | if cell.contributions == 0:
128 | count = "No contributions"
129 | elif cell.contributions == 1:
130 | count = "1 contribution"
131 | else:
132 | count = "%d contributions" % cell.contributions
133 | date_str = dateutils.display_date(cell.date)
134 | return "%s on %s" % (count, date_str)
135 |
136 |
137 | def _cell_class(values):
138 | """
139 | Returns a function which determines how a cell is highlighted.
140 | """
141 | quartiles = statistics.quartiles(values)
142 | def class_label(cell):
143 | if cell.date > dateutils.today() or cell.date < dateutils.start():
144 | return "empty"
145 | elif cell.contributions == 0:
146 | return "grad0"
147 | elif cell.contributions <= quartiles[1]:
148 | return "grad1"
149 | elif cell.contributions <= quartiles[2]:
150 | return "grad2"
151 | elif cell.contributions <= quartiles[3]:
152 | return "grad3"
153 | else:
154 | return "grad4"
155 | return class_label
156 |
157 |
158 | def filter_months(months):
159 | """
160 | We only want to print each month heading once, over the first column
161 | which contains days only from that month. This function filters a list of
162 | months so that only the first unique month heading is shown.
163 | """
164 | for idx in reversed(range(len(months))):
165 | if months[idx] == months[idx - 1]:
166 | months[idx] = ""
167 |
168 | # If the same month heading appears at the beginning and end of the year,
169 | # then only show it at the end of the year
170 | if months.count(months[0]) > 1:
171 | months[0] = ""
172 | if months.count(months[-1]) > 1:
173 | months[-1] = ""
174 |
175 | # Since each month takes up cells, we delete an empty space for each month
176 | # heading
177 | indices = [idx for idx, month in enumerate(months) if month]
178 | for idx in reversed(indices):
179 | if idx != len(months) - 1:
180 | del months[idx+1]
181 |
182 | return months
183 |
--------------------------------------------------------------------------------