├── .gitignore ├── README.md ├── chronicl.py └── helper_funcs.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | postactivate 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | venv/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | *.pot 48 | 49 | # Django stuff: 50 | *.log 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # PyBuilder 56 | target/ 57 | 58 | # logins and passwords 59 | # credentials.py 60 | 61 | 62 | .idea/ 63 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Being a huge fan of toggl.com and its nice summary diagrams, what I always missed was historical graphs. 2 | This small python program is meant to create such a graph. 3 | 4 | To try this out, enter three command line arguments: start date, end date, and your user token (which can be found at the bottom of https://www.toggl.com/app/profile ) 5 | 6 | Example: 7 | python chronicl.py 2014-08-20 2014-10-15 0853c89724897fd1877 8 | 9 | You will then be prompted to choose a workspace, choose grouping by projects/clients, and pick individual projects/clients to compare and analyze. 10 | 11 | You will get 2 plots, one with the graph itself and the other with the legend for it. 12 | 13 | 14 | Dependencies: 15 | - requests 16 | - matplotlib 17 | - base64, math, sys, re, datetime 18 | 19 | 20 | 21 | This has been tested on a single paid account with no collaborators. I am not sure how it will work in multi-user workspaces. -------------------------------------------------------------------------------- /chronicl.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import sys 3 | from datetime import datetime 4 | from helper_funcs import * 5 | import re 6 | 7 | 8 | if len(sys.argv)!=4: 9 | print("\nEnter 3 command line arguments: start date, end date, and user token.") 10 | print("Example: python chronicl.py 2014-08-15 2014-10-21 0853c89724897fd1877\n") 11 | quit() 12 | 13 | 14 | 15 | try: 16 | startdate=datetime.strptime(sys.argv[1],'%Y-%m-%d') 17 | except ValueError: 18 | print("Can't read the arguments. The date format should be YYYY-MM-DD, e.g. 2014-08-15\n") 19 | quit() 20 | try: 21 | enddate=datetime.strptime(sys.argv[2],'%Y-%m-%d') 22 | except ValueError: 23 | print("Can't read the arguments. The date format should be YYYY-MM-DD, e.g. 2014-08-15\n") 24 | quit() 25 | 26 | mondays=get_download_dates(startdate,enddate) 27 | 28 | usertoken=sys.argv[3] 29 | 30 | 31 | string=usertoken+':api_token' 32 | headers={ 33 | 'Authorization':'Basic '+base64.b64encode(string.encode('ascii')).decode("utf-8")} 34 | # headers={'Authorization':'Basic '+newstring.decode("utf-8")} 35 | url='https://www.toggl.com/api/v8/me' 36 | response=requests.get(url,headers=headers) 37 | if response.status_code!=200: 38 | print("Login failed. Check your API key") 39 | quit() 40 | 41 | response=response.json() 42 | print('response: ', response) 43 | email=response['data']['email'] 44 | 45 | workspace_ids_names=[{'name':item['name'],'id':item['id']} for item in response['data']['workspaces'] if item['admin']==True] 46 | 47 | if len(workspace_ids_names)>1: 48 | print("\nThere are more than 1 workspace with user as admin:") 49 | for w in workspace_ids_names: 50 | print(workspace_ids_names.index(w),":",w['name']) 51 | print("\nWhich workspace do you want graphed?") 52 | try: 53 | wnum = int(raw_input("Enter 0,1,2 etc.: ")) 54 | first_workspace_id=workspace_ids_names[wnum]['id'] 55 | 56 | except ValueError: 57 | print('Wrong input (non-integer).') 58 | quit() 59 | except IndexError: 60 | print('Wrong input: you dont have workspace with this number.') 61 | quit() 62 | 63 | if len(workspace_ids_names)==1: 64 | print("Only have 1 workspace") 65 | print("workspace name: ",workspace_ids_names[0]['name']) 66 | first_workspace_id=workspace_ids_names[0]['id'] 67 | quit() 68 | 69 | if len(workspace_ids_names)==0: 70 | print("There are no workspaces where user is admin. Quitting") 71 | quit() 72 | 73 | 74 | print("getting workspace clients...") 75 | 76 | 77 | # getting clients dict 78 | url='https://www.toggl.com/api/v8/workspaces/'+str(first_workspace_id)+'/clients' 79 | params={'user_agent':email,'workspace_id':first_workspace_id} 80 | clients=requests.get(url,headers=headers,params=params).json() 81 | 82 | print("getting workspace projects...") 83 | 84 | # getting projects dict 85 | url='https://www.toggl.com/api/v8/workspaces/'+str(first_workspace_id)+'/projects' 86 | project_list=requests.get(url,headers=headers,params=params).json() 87 | 88 | 89 | # going through the client dict, adding projects to each client 90 | for client in clients: 91 | client['projects']=projects_of_client(client['id'],project_list) 92 | 93 | # we also need to find all projects not attached to clients 94 | noclient={'name':'No client','id':0} 95 | noclient['projects']=projects_of_client(None,project_list) 96 | clients.append(noclient) 97 | 98 | # making a clean and simple list of clients and projects 99 | client_project_list=[] 100 | for client in clients: 101 | for project in client['projects']: 102 | 103 | client_project_list.append({'client_id':client['id'],'project_id':project['id'], 104 | 'client_name':client['name'],'project_name':project['name'], 105 | 'weekly_hours':[],'color':colordict[int(project['color'])]}) 106 | 107 | # and a final entry in our list is time spent without projects at all 108 | client_project_list.append({'client_id':None,'project_id':None, 109 | 'client_name':'No client','project_name':'No project', 110 | 'weekly_hours':[],'color':'black'}) 111 | 112 | 113 | # iterating over our project list and downloading summary hours for each week 114 | for monday in mondays: 115 | week=get_weekly_data(monday,headers,params) 116 | print("Downloading weekly data: ",monday) 117 | for item in client_project_list: 118 | item['weekly_hours'].append(project_weekly_hours(item['project_id'],week)) 119 | #print item['client_name'],":",item['project_name'],":",item['weekly_hours'] 120 | 121 | 122 | print('\nYour projects: ') 123 | for item in client_project_list: 124 | print(str(client_project_list.index(item))+" : "+item['client_name']+" : "+item['project_name']) 125 | 126 | 127 | clients_or_projects=raw_input('\nGroup by clients or by projects? (p or c) ') 128 | 129 | if clients_or_projects=='p': 130 | 131 | print('Select projects you want in your graph.') 132 | positions=raw_input('Enter integers separated by spaces, or "a" for all: ') 133 | 134 | if positions!="a": 135 | positions=re.sub(' +',' ',positions).split(' ') 136 | try: 137 | positions=[int(p) for p in positions] 138 | except ValueError: 139 | print('Wrong input. Enter integers separated by spaces.') 140 | quit() 141 | client_project_list=[item for item in client_project_list if client_project_list.index(item) in positions] 142 | 143 | 144 | 145 | 146 | # and plotting the whole bunch 147 | plot_result(client_project_list,mondays) 148 | 149 | elif clients_or_projects=='c': 150 | 151 | 152 | 153 | client_list=[] 154 | clientnames=[item['client_name'] for item in client_project_list] 155 | clientnames=list(set(clientnames)) 156 | 157 | 158 | print('\nYour clients:\n') 159 | for item in clientnames: 160 | print(str(clientnames.index(item))+" : "+item) 161 | 162 | 163 | print('\nSelect clients you want in your graph.') 164 | positions=raw_input('Enter integers separated by spaces, or "a" for all: ') 165 | if positions!='a': 166 | positions=re.sub(' +',' ',positions).split(' ') 167 | try: 168 | positions=[int(p) for p in positions] 169 | except ValueError: 170 | print('Wrong input. Enter integers separated by spaces.') 171 | quit() 172 | clientnames=[name for name in clientnames if clientnames.index(name) in positions] 173 | 174 | for name in clientnames: 175 | client_list.append({'client_id':None,'project_id':None, 176 | 'client_name':name,'project_name':None, 177 | 'weekly_hours':all_project_hours(client_project_list,name)['hours'], 178 | 'color':all_project_hours(client_project_list,name)['color']}) 179 | 180 | 181 | 182 | plot_result(client_list,mondays,projectnames=False) 183 | 184 | else: 185 | print("Wrong input. Enter p or c") 186 | -------------------------------------------------------------------------------- /helper_funcs.py: -------------------------------------------------------------------------------- 1 | from matplotlib import pyplot as plt 2 | import matplotlib.patches as mpatches 3 | from datetime import timedelta 4 | from math import floor 5 | import requests 6 | 7 | 8 | 9 | colordict={0:'#4dc3ff', 10 | 1:'#bc85e6', 11 | 2:'#df7baa', 12 | 3:'#f68d38', 13 | 4:'#b27636', 14 | 5:'#8ab734', 15 | 6:'#14a88e', 16 | 7:'#268bb5', 17 | 8:'#6668b4', 18 | 9:'#a4506c', 19 | 10:'#67412c', 20 | 11:'#3c6526', 21 | 12:'#094558', 22 | 13:'#bc2d07', 23 | 14:'#999999'} 24 | 25 | 26 | # search the JSON response for occurences of project_id, return project summary time in minutes 27 | def project_weekly_hours(project_id,response): 28 | for entry in response['data']: 29 | if entry.get('pid')==project_id: 30 | return round(entry['totals'][7]/float(3600000),2) 31 | return 0 32 | 33 | # get weekly summary starting from start_date 34 | def get_weekly_data(start_date,headers,params): 35 | url='https://toggl.com/reports/api/v2/weekly' 36 | params['since']=start_date 37 | response=requests.get(url,headers=headers,params=params).json() 38 | return response 39 | 40 | def projects_of_client(client_id,project_list): 41 | c_projects=[project for project in project_list if project.get('cid')==client_id] 42 | return c_projects 43 | 44 | 45 | 46 | def vector_sum(v1,v2): 47 | length1=len(v1) 48 | result=[round(v1[i]+v2[i],2) for i in range(0,length1)] 49 | return result 50 | 51 | 52 | def plot_result(data,mondays,projectnames=True): 53 | handles,labels=[],[] 54 | 55 | fig2,legend=plt.subplots() 56 | fig,graph=plt.subplots() 57 | graph.set_ylabel('Weekly hours') 58 | 59 | lower_line=[0]*len(data[0]['weekly_hours']) 60 | x=list(range(0,len(lower_line))) 61 | plt.xticks(x,mondays,rotation='vertical') 62 | for item in data: 63 | if projectnames: 64 | labels.append(item['client_name'] + " : " + item['project_name']) 65 | else: 66 | labels.append(item['client_name']) 67 | handles.append(mpatches.Patch(color=item['color'])) 68 | upper_line=vector_sum(item['weekly_hours'],lower_line) 69 | graph.fill_between(x,upper_line,lower_line,color=item['color'],edgecolor='#555555') 70 | #graph.plot(x,upper_line,color=item['color'],antialiased=True,linewidth=5) 71 | lower_line=upper_line 72 | 73 | #plt.subplots_adjust(left=0.1, right=0.9, top=0.9, bottom=0.1) 74 | legend.legend(handles,labels,loc=(0.05,0.05),fancybox=True) 75 | 76 | #plt.subplots_adjust(right=0.5) 77 | #plt.savefig('result.png') 78 | plt.tight_layout() 79 | plt.show() 80 | 81 | 82 | i=0 83 | 84 | def all_project_hours(data,client_name): 85 | global i 86 | 87 | hours=[0]*len(data[0]['weekly_hours']) 88 | 89 | for item in data: 90 | if item['client_name']==client_name: 91 | hours=vector_sum(hours,item['weekly_hours']) 92 | 93 | color=colordict[i] 94 | i+=1 if i<=14 else 0 95 | 96 | return {'hours':hours,'color':color} 97 | 98 | 99 | def get_download_dates(startdate,enddate): 100 | # IF start date is not the beginning of the week (not Monday), 101 | # We will start from the Monday of the week selected." 102 | if startdate.weekday()!=0: 103 | startdate=startdate-timedelta(days=startdate.weekday()) 104 | 105 | 106 | # calculating number of weeks from the number of days. 107 | # If the range is 13 days, this is considered as 1 week because the last week is not finished yet 108 | # and we can't make a report if we don't have all the data. 109 | weeks_number=int(floor(float((enddate-startdate).days)/7)) 110 | 111 | # to get all the data for the selected range, we need to divide it into 7-day chunks 112 | mondays=[] 113 | for monday in range(0,weeks_number): 114 | mondays.append(startdate.strftime('%Y-%m-%d')) 115 | startdate+=timedelta(days=7) 116 | 117 | return mondays --------------------------------------------------------------------------------