├── 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 |
10 | {%- for graph in graphs %} 11 | {% include "graph.html" -%} 12 | {% endfor %} 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /example.txt: -------------------------------------------------------------------------------- 1 | # A hash at the start of the line marks a comment 2 | 2014-03-01 7 3 | 2015-04-21 8 4 | 5 | # You can have blank lines as well 6 | 2014-09-24 65 7 | 2014-12-15 77 8 | 2015-03-13 97# or even a comment on the same line as values 9 | 10 | # And here are some more random values: 11 | 2014-12-14 46 12 | 2014-10-28 19 13 | 2014-12-10 74 14 | 2014-12-19 5 15 | 2014-10-14 62 16 | 2015-02-10 50 17 | 2014-06-11 57 18 | 2014-07-25 30 19 | 2014-06-21 62 20 | 2015-01-01 98 21 | 2015-05-23 43 22 | 2015-03-23 32 23 | 2014-09-27 85 24 | 2014-10-15 42 25 | 2014-11-28 47 26 | 2014-08-06 93 27 | 2014-08-21 57 28 | 2015-04-05 39 29 | 2015-05-10 46 30 | 2014-06-28 58 31 | 2014-08-14 9 32 | 2014-11-14 44 33 | 2015-02-01 42 34 | 2014-10-14 100 35 | 2014-12-12 85 36 | 2014-10-07 34 37 | 2015-04-26 92 38 | 2014-12-31 14 39 | 2014-08-25 12 40 | 2015-03-10 11 41 | 2014-12-15 98 42 | 2015-04-16 66 43 | 2014-06-18 33 44 | 2014-08-19 73 45 | 2015-01-16 1 46 | 2015-03-29 16 47 | 2014-09-03 97 48 | 2015-04-25 30 49 | 2015-04-17 15 50 | 2014-05-31 46 51 | 2015-03-19 15 52 | 2014-11-12 9 53 | 2014-11-15 18 54 | 2015-01-20 23 55 | 2014-09-20 25 56 | 2015-02-11 53 57 | 2014-09-17 66 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Alex Chan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /contributions/statistics.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import dateutils 4 | 5 | 6 | def quartiles(values): 7 | """ 8 | Returns the (rough) quintlines of a series of values. This is not intended 9 | to be statistically correct - it's not a quick 'n' dirty measure. 10 | """ 11 | return [i * max(values) / 4 for i in range(5)] 12 | 13 | 14 | def longest_streak(dates): 15 | """ 16 | Given a list of datetime.date objects, return the longest sublist of 17 | consecutive dates. If there are multiple longest sublists of the same 18 | length, then the first such sublist is returned. 19 | """ 20 | if not dates: 21 | return [] 22 | dates = sorted(dates) 23 | 24 | streaks = [] 25 | current_streak = [dates[0]] 26 | 27 | # For each date, check to see whether it extends the current streak 28 | for idx in range(1, len(dates)): 29 | date = dates[idx] 30 | if dateutils.previous_day(date) == current_streak[-1]: 31 | current_streak.append(date) 32 | else: 33 | streaks.append(current_streak) 34 | current_streak = [date] 35 | 36 | # When we've gone through all the dates, save the last streak 37 | streaks.append(current_streak) 38 | 39 | return max(streaks, key=len) 40 | 41 | 42 | def current_streak(dates): 43 | """ 44 | Given a list of datetime.date objects, return today's date (if present) 45 | and all/any preceding consecutive dates. 46 | """ 47 | streak = [] 48 | current_date = dateutils.today() 49 | 50 | while current_date in dates: 51 | streak.append(current_date) 52 | current_date = dateutils.previous_day(current_date) 53 | 54 | return sorted(streak) -------------------------------------------------------------------------------- /contributions/tests/parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Unit tests for contributions.parser. 4 | """ 5 | from collections import defaultdict 6 | import datetime 7 | import logging 8 | import os 9 | import sys 10 | import unittest 11 | 12 | sys.path.append(os.getcwd()) 13 | from contributions import parser 14 | 15 | logging.disable(logging.CRITICAL) 16 | 17 | 18 | def _testfile(filename): 19 | return os.path.join(os.getcwd(), "contributions", "tests", filename) 20 | 21 | 22 | class TestParserMethods(unittest.TestCase): 23 | 24 | def test_parse_line(self): 25 | 26 | EMPTY_LINES = [ 27 | "# this is a commented line", 28 | " # this is a commented line with leading whitespace" 29 | ] 30 | for line in EMPTY_LINES: 31 | self.assertEqual(parser._parse_line(line), None) 32 | 33 | INVALID_LINES = [ 34 | "too many spaces", 35 | "2015a-10-11 123", 36 | "2015-10-11 123a" 37 | ] 38 | for line in INVALID_LINES: 39 | with self.assertRaises(ValueError): 40 | parser._parse_line(line) 41 | 42 | VALID_LINES = { 43 | "2015-10-11 123": (datetime.date(2015, 10, 11), 123), 44 | "2016-11-9 18": (datetime.date(2016, 11, 9), 18) 45 | } 46 | for line, output in VALID_LINES.iteritems(): 47 | self.assertEqual(parser._parse_line(line), output) 48 | 49 | def test_parse_file(self): 50 | self.assertEqual( 51 | parser._parse_file(_testfile("file1.txt")), 52 | defaultdict(int) 53 | ) 54 | 55 | self.assertEqual( 56 | parser._parse_file(_testfile("file2.txt")), 57 | defaultdict(int, { 58 | datetime.date(2012, 10, 9): 123, 59 | datetime.date(2012, 10, 8): 124 60 | }) 61 | ) 62 | 63 | self.assertEqual( 64 | parser._parse_file(_testfile("file3.txt")), 65 | defaultdict(int, { 66 | datetime.date(2012, 10, 9): 246, 67 | datetime.date(2012, 10, 8): 124 68 | }) 69 | ) 70 | 71 | 72 | if __name__ == '__main__': 73 | unittest.main() 74 | -------------------------------------------------------------------------------- /contributions/parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | The record of contributions is in a text file, where each line is in the 4 | following format: 5 | 6 | YYYY-MM-DD value 7 | 8 | Excess whitespace is not significant, and text after a hash (#) will be treated 9 | as a comment. Blank lines are skipped. 10 | 11 | This module is responsible for parsing the output of this file, and turning it 12 | into a dictionary of date/contribution count pairs. 13 | """ 14 | 15 | from collections import defaultdict 16 | import datetime 17 | import logging 18 | 19 | 20 | def _parse_line(original_line): 21 | """ 22 | Parse the output of a single line from the file. Returns a (date, count) 23 | tuple if the line contains content, or None if the line contains no 24 | content. 25 | """ 26 | # Remove any comments and excess whitespace from the line 27 | line = original_line.split("#")[0].strip() 28 | 29 | # If the line is empty, then there's nothing more to do 30 | if not line: 31 | return 32 | 33 | # Split the string into a date string, and a value 34 | try: 35 | date_str, count_str = line.split() 36 | 37 | # Try to coerce the date string into a datetime.date object: 38 | try: 39 | date = datetime.datetime.strptime(date_str, "%Y-%m-%d").date() 40 | except ValueError: 41 | logging.warning("Invalid date in line:{}".format(original_line)) 42 | raise 43 | 44 | # Try to coerce the count into an int 45 | try: 46 | count = int(count_str) 47 | except ValueError: 48 | logging.warning("Invalid count in line: {}".format(original_line)) 49 | raise 50 | 51 | # If the line has too many or too few values separated by spaces, then a 52 | # ValueError will be raised. 53 | except ValueError: 54 | logging.warning("Invalid line:{}".format(original_line)) 55 | raise 56 | 57 | return (date, count) 58 | 59 | 60 | def parse_file(filepath): 61 | """ 62 | Parse the output of a file containing contribution data. Returns a dict of 63 | date/count pairs. 64 | """ 65 | contributions = defaultdict(int) 66 | 67 | with open(filepath) as f: 68 | for line in f: 69 | line_output = _parse_line(line) 70 | if line_output is not None: 71 | date, count = line_output 72 | contributions[date] += count 73 | return contributions 74 | 75 | -------------------------------------------------------------------------------- /contributions/dateutils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import datetime 4 | 5 | 6 | def today(): 7 | """ 8 | Gets the current date. Wrapper function to make it easier to stub out in 9 | tests. 10 | """ 11 | return datetime.date.today() 12 | 13 | 14 | def start(): 15 | """ 16 | Gets the date from one year ago, which is the start of the contributions 17 | graph. 18 | """ 19 | return datetime.date(today().year - 1, today().month, today().day) 20 | 21 | 22 | def display_date(date): 23 | """ 24 | Returns a long date string. Example output: "May 24, 2015". 25 | """ 26 | return date.strftime("%B %d, %Y").replace(" 0", " ") 27 | 28 | 29 | def previous_day(date): 30 | """ 31 | Returns the previous day as a datetime.date object. 32 | """ 33 | return date - datetime.timedelta(1) 34 | 35 | 36 | def next_day(date): 37 | """ 38 | Returns the next day as a datetime.date object. 39 | """ 40 | return date + datetime.timedelta(1) 41 | 42 | 43 | def elapsed_time(date): 44 | """ 45 | Given a date in the past, return a human-readable string explaining how 46 | long ago it was. 47 | """ 48 | if date > today(): 49 | raise ValueError("Date {} is in the future, not the past".format(date)) 50 | 51 | difference = (today() - date).days 52 | 53 | # I'm treating a month as ~30 days. This may be a little inaccurate in some 54 | # months, but it's good enough for our purposes. 55 | if difference == 1: 56 | return "a day ago" 57 | elif difference < 30: 58 | return "%d days ago" % difference 59 | elif difference < 30 * 2: 60 | return "a month ago" 61 | elif difference < 366: 62 | return "%d months ago" % (difference / 30) 63 | else: 64 | return "more than a year ago" 65 | 66 | def weekday_initials(): 67 | """ 68 | Returns a list of abbreviations for the days of the week, starting with 69 | Sunday. 70 | """ 71 | # Get a week's worth of date objects 72 | week = [today() + datetime.timedelta(i) for i in range(7)] 73 | 74 | # Sort them so that Sunday is first 75 | week = sorted(week, key=lambda day: (day.weekday() + 1) % 7) 76 | 77 | # Get the abbreviated names of the weekdays 78 | day_names = [day.strftime("%a") for day in week] 79 | 80 | # Now reduce the names to minimal unique abbreviations (in practice, this 81 | # means one or two-letter abbreviations). 82 | short_names = [] 83 | 84 | # For each day of the week, start with the first letter, and keep adateing 85 | # letters until we have a unique abbreviation. 86 | for idx in range(7): 87 | day_name = day_names[idx] 88 | length = 1 89 | 90 | # This list comprehension finds collisions: other day names which match 91 | # the first (length) characters of this day. 92 | while [day for day in day_names 93 | if day[:length] == day_name[:length] 94 | and day != day_name]: 95 | length += 1 96 | 97 | short_names.append(day_name[:length]) 98 | 99 | return short_names 100 | -------------------------------------------------------------------------------- /contributions/tests/dateutils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Unit tests for contributions.dateutils. 4 | """ 5 | 6 | from datetime import date 7 | import locale 8 | import os 9 | import sys 10 | import unittest 11 | 12 | import mock 13 | 14 | sys.path.append(os.getcwd()) 15 | from contributions import dateutils 16 | 17 | 18 | dateutils._today = mock.Mock() 19 | dateutils._today.return_value = date(2015, 4, 24) 20 | 21 | 22 | INCREMENT_CASES = [ 23 | [date(2015, 5, 29), date(2015, 5, 28), False], 24 | [date(2015, 5, 25), date(2015, 5, 24), False], 25 | [date(2015, 5, 25), date(2015, 5, 22), True], 26 | ] 27 | 28 | WEEKDAY_NAMES = { 29 | 'en_US': ['M', 'Tu', 'W', 'Th', 'F', 'Sa', 'Su'], 30 | 'de_DE': ['Mo', 'Di', 'Mi', 'Do', 'F', 'Sa', 'So'], 31 | 'fr_FR': ['L', 'Ma', 'Me', 'J', 'V', 'S', 'D'] 32 | } 33 | 34 | PAST_DATE_STRINGS = { 35 | date(2015, 4, 23): "a day ago", 36 | date(2015, 4, 21): "3 days ago", 37 | date(2015, 3, 26): "29 days ago", 38 | date(2015, 3, 25): "a month ago", 39 | date(2014, 8, 25): "8 months ago", 40 | date(2014, 4, 24): "12 months ago", 41 | date(2014, 4, 23): "more than a year ago", 42 | } 43 | 44 | 45 | class TestDateutilMethods(unittest.TestCase): 46 | 47 | def test_weekday(self): 48 | self.assertEqual(dateutils.is_weekday(date(2015, 5, 3)), False) 49 | self.assertEqual(dateutils.is_weekday(date(2015, 5, 4)), True) 50 | self.assertEqual(dateutils.is_weekday(date(2015, 5, 5)), True) 51 | self.assertEqual(dateutils.is_weekday(date(2015, 5, 6)), True) 52 | self.assertEqual(dateutils.is_weekday(date(2015, 5, 7)), True) 53 | self.assertEqual(dateutils.is_weekday(date(2015, 5, 8)), True) 54 | self.assertEqual(dateutils.is_weekday(date(2015, 5, 9)), False) 55 | 56 | def test_is_within_last_year(self): 57 | self.assertEqual( 58 | dateutils.is_within_last_year(date(2008, 10, 8)), False) 59 | self.assertEqual( 60 | dateutils.is_within_last_year(date(2006, 10, 8)), False) 61 | self.assertEqual( 62 | dateutils.is_within_last_year(date(2015, 4, 21)), True) 63 | 64 | # We count the same date on the previous year as "within one year", but 65 | # not the day before that 66 | self.assertEqual( 67 | dateutils.is_within_last_year(date(2014, 4, 23)), False) 68 | self.assertEqual( 69 | dateutils.is_within_last_year(date(2014, 4, 24)), True) 70 | 71 | # Future dates aren't within the past year 72 | self.assertEqual( 73 | dateutils.is_within_last_year(date(2016, 8, 21)), False) 74 | 75 | def test_previous_day(self): 76 | for date1, date2, skip in INCREMENT_CASES: 77 | self.assertEqual(dateutils.previous_day(date1, skip), date2) 78 | 79 | def test_next_day(self): 80 | for date2, date1, skip in INCREMENT_CASES: 81 | self.assertEqual(dateutils.next_day(date1, skip), date2) 82 | 83 | def test_weekday_initials(self): 84 | for locale_code, weekdays in WEEKDAY_NAMES.iteritems(): 85 | locale.setlocale(locale.LC_ALL, locale_code) 86 | self.assertEqual(dateutils.weekday_initials(), weekdays) 87 | 88 | def test_past_date_str(self): 89 | for date_obj, date_str in PAST_DATE_STRINGS.iteritems(): 90 | self.assertEqual(dateutils.past_date_str(date_obj), date_str) 91 | 92 | if __name__ == '__main__': 93 | unittest.main() 94 | -------------------------------------------------------------------------------- /contributions/templates/graph.html: -------------------------------------------------------------------------------- 1 | {%- set data = graph.data -%} 2 |
3 |
Contributions for {{ graph.repo_name }}
4 |
5 |
6 |
7 |
8 |
9 | {% for month in months -%} 10 | {% if month -%} 11 |
{{ month }}
12 | {% else -%} 13 |
14 | {% endif -%} 15 | {% endfor -%} 16 |
17 | {% for row in data -%} 18 |
19 |
20 | {{ weekdays[loop.index-1] }} 21 |
22 | {% for cell in row -%} 23 |
24 | {{ cell|tooltip }} 25 |
26 | {% endfor %} 27 |
28 | {% endfor %} 29 |
30 |
31 | {% for _ in range(graph.data[0]|length - 6) %} 32 |
33 | {% endfor %} 34 |
Less
35 |
36 |
37 |
38 |
39 |
40 |
 More
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |

Total contributions

50 |

{{ graph.sum }}

51 |

{{ start|display_date }} – {{ today|display_date }}

52 |
53 |
54 |

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 |
63 |
64 |

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 |
77 |
78 |
-------------------------------------------------------------------------------- /contributions/static/style.scss: -------------------------------------------------------------------------------- 1 | $cell-dimension: 15px; 2 | $column-width: 1px; 3 | 4 | $tooltip-bg: rgba(0.85, 0.85, 0.85, 0.8); 5 | $tooltip-border: rgb(0.85, 0.85, 0.85); 6 | $tooltip-color: white; 7 | 8 | $accent-gray: #aaa; 9 | 10 | $tooltip-width: 235px; 11 | $tooltip-height: 30px; 12 | $tooltip-top: -45px; 13 | 14 | .contrib_grad0 { background-color: #eee; } 15 | .contrib_grad1 { background-color: #d6e685; } 16 | .contrib_grad2 { background-color: #8cc665; } 17 | .contrib_grad3 { background-color: #44a340; } 18 | .contrib_grad4 { background-color: #1e6823; } 19 | 20 | 21 | /*------------------------------------*\ 22 | # PANEL STYLES 23 | \*------------------------------------*/ 24 | 25 | .panel { 26 | margin-left: auto; 27 | margin-right: auto; 28 | margin-top: 40px; 29 | margin-bottom: 40px; 30 | } 31 | 32 | .panel-heading { 33 | font-size: 1.5em; 34 | font-weight: bold; 35 | text-align: center; 36 | } 37 | 38 | 39 | /*------------------------------------*\ 40 | # CELLS 41 | \*------------------------------------*/ 42 | 43 | .calendar-row { 44 | display: table; 45 | border-spacing: ($column-width * 2) $column_width; 46 | } 47 | 48 | .cell { 49 | width: $cell-dimension; 50 | height: $cell-dimension; 51 | min-width: $cell-dimension; 52 | min-height: $cell-dimension; 53 | margin: $column-width; 54 | display: table-cell; 55 | resize: none; 56 | } 57 | 58 | .cell:hover { 59 | border: 1px solid #333; 60 | } 61 | 62 | .contrib_empty:hover { 63 | border: none; 64 | } 65 | 66 | .weekday-heading { 67 | color: #ccc; 68 | text-align: right; 69 | font-size: 0.8em; 70 | padding-right: 3px; 71 | width: $cell-dimension * 2; 72 | } 73 | 74 | .month-heading { 75 | width: 2 * ($cell-dimension + $column-width); 76 | font-size: 0.8em; 77 | text-align: left; 78 | color: #aaa; 79 | } 80 | 81 | .legend { 82 | margin-top: 16px; 83 | } 84 | 85 | 86 | /*------------------------------------*\ 87 | # TOOLTIPS 88 | \*------------------------------------*/ 89 | 90 | // This tooltip code isn't mine - there are quite a few CSS-only tooltips 91 | // if you search Google. I don't remember where I got this originally; it's 92 | // been sitting in my back pocket for a while. :-/ 93 | 94 | .cell { 95 | position: relative; 96 | z-index: 24; 97 | } 98 | 99 | .cell:hover { 100 | z-index: 25; 101 | } 102 | 103 | .cell span { 104 | display: none; 105 | } 106 | 107 | .cell:hover span { 108 | width: $tooltip-width; 109 | height: $tooltip-height; 110 | top: $tooltip-top; 111 | left: -($tooltip-width - $cell_dimension) / 2; 112 | 113 | display: inline-block; 114 | position: absolute; 115 | 116 | background-color: $tooltip-bg; 117 | color: $tooltip-color; 118 | text-align: center; 119 | font-size: 12px; 120 | border-radius: 4px; 121 | line-height: $tooltip-height; 122 | vertical-align: middle; 123 | } 124 | 125 | .cell:hover span:before { 126 | border: solid; 127 | border-color: $tooltip-bg transparent; 128 | border-width: 7px 7px 0 7px; 129 | bottom: -7px; 130 | content: ""; 131 | left: ($tooltip-width - $cell_dimension) / 2; 132 | position: absolute; 133 | z-index: 99; 134 | } 135 | 136 | .contrib_empty:hover span { 137 | display: none; 138 | } 139 | 140 | 141 | /*------------------------------------*\ 142 | # STATISTICS 143 | \*------------------------------------*/ 144 | 145 | .statistics.row { 146 | border-top: 1px solid #ddd; 147 | width: 100%; 148 | margin-left: auto; 149 | margin-right: auto; 150 | } 151 | 152 | .statistics .col-md-4 { 153 | text-align: center; 154 | padding-bottom: 10px; 155 | padding-top: 12px; 156 | } 157 | 158 | .col-md-4.middle { 159 | border-left: 1px solid #ddd; 160 | border-right: 1px solid #ddd; 161 | } 162 | 163 | .annotation { 164 | color: $accent-gray; 165 | font-size: 0.85em; 166 | margin-bottom: 2px; 167 | } 168 | 169 | .big_stat { 170 | font-size: 2.4em; 171 | margin-bottom: 5px; 172 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # contribution-graph 2 | 3 | This is a clone of the Contributions chart from the GitHub user page, written in Python. 4 | 5 | This is what it looks like with the default settings: 6 | 7 | 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 `
`, it should be fairly easy to recolour and reshape. 73 | 74 | Here are a few that I came up with by just tweaking the CSS by hand: 75 | 76 | 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 | --------------------------------------------------------------------------------