├── README.rst ├── track.sh ├── track-gnome.sh └── report.py /README.rst: -------------------------------------------------------------------------------- 1 | Activity Tracker 2 | =================== 3 | 4 | Track what you are spending your time on. 5 | 6 | * It logs the current open window title and whether the user is active every 15s. 7 | * Reports to be done. Ultimately I want to classify in "deep work" and "slacking" and see when I am most productive. 8 | 9 | 10 | Installation 11 | ============= 12 | 13 | Place into ~/.config/autostart/activitytracker.desktop:: 14 | 15 | [Desktop Entry] 16 | Name=Activity Tracker 17 | GenericName=Tracks what you spend your time on 18 | Exec=/home/user/Downloads/activitytracker/tracker.sh 19 | StartupNotify=false 20 | Terminal=false 21 | Version=1.0 22 | Categories=Utility; 23 | Type=Application 24 | X-GNOME-Autostart-enabled=true 25 | 26 | Then restart. 27 | 28 | Or start it manually with:: 29 | 30 | $ /home/user/Downloads/activitytracker/tracker.sh 31 | 32 | Watch the recording:: 33 | 34 | $ tail -f ~/.local/share/activitytracker/log 35 | 36 | Reports 37 | ============= 38 | 39 | * Create ~/.local/share/activitytracker/classes defining in each line: 40 | 41 | * Name of class 42 | * \t as separator 43 | * Regular expression for matching "title :: executable" 44 | 45 | for example:: 46 | 47 | Hobby ~/Downloads/activitytracker 48 | Lit JabRef 49 | Programming /usr/bin/gedit 50 | Programming IPython 51 | Programming /usr/lib/gnome-terminal/gnome-terminal-server 52 | 53 | * The first matching class is assigned. 54 | 55 | * run report.py:: 56 | 57 | $ python report.py 58 | 59 | day of the year 60 | | 61 | | hour of day (four for each 15 minutes 62 | | | 63 | v v 64 | DDD-HH Hobb Lit Prog Rese <-- classes 65 | 66-16 ==== 66 | 66-16 ==== 67 | 66-16 === == 68 | 66-17 ==== = 69 | 66-17 ==== 70 | 66-17 === == 71 | 66-18 = = === 72 | 66-18 ==== = 73 | \^ 74 | | 75 | bar shows time fraction 76 | spend on that class 77 | 78 | 79 | License 80 | ========== 81 | 82 | 2-clause BSD 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /track.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Tracks what you are spending your time on 4 | # 5 | # 6 | # Copyright (c) 2019 Johannes Buchner 7 | # All rights reserved. 8 | # 9 | # Redistribution and use in source and binary forms, with or without 10 | # modification, are permitted provided that the following conditions are met: 11 | # 12 | # 1. Redistributions of source code must retain the above copyright notice, this 13 | # list of conditions and the following disclaimer. 14 | # 15 | # 2. Redistributions in binary form must reproduce the above copyright notice, 16 | # this list of conditions and the following disclaimer in the documentation 17 | # and/or other materials provided with the distribution. 18 | # 19 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | # 30 | 31 | DIR=$HOME/.local/share/activitytracker/ 32 | mkdir -p $DIR 33 | 34 | if [ -e $DIR/pid ] 35 | then 36 | pid=$(cat $DIR/pid) 37 | # check if still running 38 | if kill -0 ${pid} 2>/dev/null 39 | then 40 | # check if related 41 | if grep -q $0 /proc/${pid}/cmdline 42 | then 43 | echo "activity tracker already runnning as PID=$pid" 44 | exit 0 45 | fi 46 | fi 47 | fi 48 | 49 | pid=$$ 50 | echo $pid > $DIR/pid 51 | 52 | function readkeys { 53 | xinput list | grep -Po 'id=\K\d+(?=.*slave\s*(keyboard|pointer))' | xargs -rt -P0 -n1 xinput test 54 | } 55 | 56 | 57 | while true 58 | do 59 | 60 | nkeys=$(xinput list | grep -Po 'id=\K\d+(?=.*slave\s*(keyboard|pointer))' | xargs -P0 -n1 timeout 5s xinput test|wc -l) 61 | 62 | echo '{"timestamp":'$(date +%s)',"nevents":'${nkeys}',"windowname":"'$(xdotool getwindowfocus getwindowname|sed 's,",\",g')'","exe":"'$(readlink /proc/$(xdotool getwindowfocus getwindowpid)/exe|sed 's,",\",g')'"}' 63 | 64 | done >> $DIR/log 65 | 66 | 67 | -------------------------------------------------------------------------------- /track-gnome.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Tracks what you are spending your time on 4 | # 5 | # 6 | # Copyright (c) 2019-2023 Johannes Buchner 7 | # All rights reserved. 8 | # 9 | # Redistribution and use in source and binary forms, with or without 10 | # modification, are permitted provided that the following conditions are met: 11 | # 12 | # 1. Redistributions of source code must retain the above copyright notice, this 13 | # list of conditions and the following disclaimer. 14 | # 15 | # 2. Redistributions in binary form must reproduce the above copyright notice, 16 | # this list of conditions and the following disclaimer in the documentation 17 | # and/or other materials provided with the distribution. 18 | # 19 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | # 30 | 31 | DIR=$HOME/.local/share/activitytracker/ 32 | mkdir -p $DIR 33 | 34 | if [ -e $DIR/pid ] 35 | then 36 | pid=$(cat $DIR/pid) 37 | # check if still running 38 | if kill -0 ${pid} 2>/dev/null 39 | then 40 | # check if related 41 | if grep -q $0 /proc/${pid}/cmdline 42 | then 43 | echo "activity tracker already runnning as PID=$pid" 44 | exit 0 45 | fi 46 | fi 47 | fi 48 | 49 | pid=$$ 50 | echo $pid > $DIR/pid 51 | 52 | function window_call_info { 53 | # call Gnome extension "Window Calls extended" 54 | # https://extensions.gnome.org/extension/4974/window-calls-extended/ 55 | # needs to be installed! 56 | # sed removes all quotes from window title, to avoid json encoding issues 57 | gdbus call --session --dest org.gnome.Shell --object-path /org/gnome/Shell/Extensions/WindowsExt --method org.gnome.Shell.Extensions.WindowsExt.$1 | 58 | sed -n "s/^('\(.*\)',)$/\1/g; s/['\"]//g; p" 59 | } 60 | 61 | while true 62 | do 63 | echo '{"timestamp":'$(date +%s)',"windowname":"'$(window_call_info FocusTitle)'","exe":"'$(window_call_info FocusClass)'"}'; 64 | sleep 10; 65 | done >> $DIR/log 66 | -------------------------------------------------------------------------------- /report.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Reports what you are spending your time on 4 | # 5 | # Copyright (c) 2019 Johannes Buchner 6 | # All rights reserved. 7 | # 8 | # Redistribution and use in source and binary forms, with or without 9 | # modification, are permitted provided that the following conditions are met: 10 | # 11 | # 1. Redistributions of source code must retain the above copyright notice, this 12 | # list of conditions and the following disclaimer. 13 | # 14 | # 2. Redistributions in binary form must reproduce the above copyright notice, 15 | # this list of conditions and the following disclaimer in the documentation 16 | # and/or other materials provided with the distribution. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | # 29 | 30 | import os 31 | import datetime 32 | from collections import Counter 33 | import re 34 | import json 35 | 36 | dir = os.path.expanduser('~/.local/share/activitytracker/') 37 | 38 | def load_patterns(): 39 | patterns = [] 40 | for line in open(dir + 'classes'): 41 | if line.startswith('#'): continue 42 | if line.strip() == '': continue 43 | activity_class, pattern = line.strip().split('\t', 1) 44 | patterns.append((activity_class, re.compile(pattern))) 45 | return patterns 46 | 47 | patterns = load_patterns() 48 | classes = sorted({activity_class for activity_class, _ in patterns}) 49 | 50 | def find_matching_pattern(title): 51 | for pattern_class, pattern in patterns: 52 | if pattern.search(title) is not None: 53 | return pattern_class 54 | 55 | 56 | t0 = datetime.datetime.now() 57 | 58 | 59 | def read_classes(): 60 | #lastt = None 61 | lastclass = None 62 | lasttitle = None 63 | 64 | for line in open(dir + 'log'): 65 | try: 66 | item = json.loads(line.strip()) 67 | except json.decoder.JSONDecodeError: 68 | #print("Issue parsing line '%s'" % line.strip()) 69 | continue 70 | t, nevents, title = item.get('timestamp'), item.get('nevents',1), item.get('windowname','') + ' :: ' + item.get('exe','') 71 | 72 | t = datetime.datetime.fromtimestamp(t) 73 | 74 | if nevents < 1: 75 | continue 76 | 77 | #if lastt is not None and (t - lastt).days > 7: 78 | # continue 79 | 80 | #lastt = t 81 | if lasttitle is not None and title == lasttitle: 82 | activity_class = lastclass 83 | else: 84 | activity_class = find_matching_pattern(title) 85 | 86 | if activity_class is None: 87 | activity_class = 'unclassified' 88 | 89 | yield item, t, title, activity_class 90 | 91 | lasttitle, lastclass = title, activity_class 92 | 93 | 94 | # we want each row to be a 30 minute window 95 | # and compute the fraction of time spent on each category 96 | # and plot over time 97 | 98 | def read_buckets(): 99 | lastbucket = None 100 | currentcounter = Counter() 101 | knowncounter = Counter() 102 | unknowncounter = Counter() 103 | 104 | for item, time, title, activity_class in read_classes(): 105 | startofyear = datetime.date(year=time.year,month=1,day=1) 106 | startofday = datetime.datetime(year=time.year,month=time.month,day=time.day,hour=0,minute=0,second=0, tzinfo=time.tzinfo) 107 | timebucket = ( 108 | time.year, 109 | (time.date() - startofyear).days, 110 | int((time - startofday).total_seconds() / 60 / 15), 111 | ) 112 | #print(time, time.tzinfo, timebucket) 113 | if lastbucket is None or lastbucket != timebucket: 114 | if lastbucket is not None: 115 | yield lastbucket, currentcounter, knowncounter, unknowncounter 116 | currentcounter = Counter() 117 | knowncounter = Counter() 118 | unknowncounter = Counter() 119 | 120 | if activity_class == 'unclassified': 121 | unknowncounter[title] += 1 122 | else: 123 | knowncounter[title] += 1 124 | currentcounter[activity_class] += 1 125 | lastbucket = timebucket 126 | 127 | if lastbucket is not None: 128 | yield lastbucket, currentcounter, knowncounter, unknowncounter 129 | 130 | def fmt(c,s): 131 | if c > s * 3 / 4: 132 | return '====' 133 | elif c > s * 2 / 4: 134 | return '=== ' 135 | elif c > s * 1 / 4: 136 | return '== ' 137 | elif c > s * 1 / 10: 138 | return '= ' 139 | else: 140 | return ' ' 141 | 142 | print('DDD HH %s' % (' '.join(['%-4s' % c[:4] for c in classes]))) 143 | lastbucket = None 144 | for bucket, counter, knowns, unknowns in read_buckets(): 145 | s = sum(counter.values()) 146 | u = '' 147 | if unknowns: 148 | mostcommon, nunknown = unknowns.most_common(1)[0] 149 | if nunknown * 10 > s or True: 150 | u = ' | %.2f%%: %s' % (nunknown * 100 / s, mostcommon) 151 | else: 152 | mostcommon, n = knowns.most_common(1)[0] 153 | u = ' | %.2f%%: %s' % (n * 100 / s, mostcommon) 154 | 155 | if lastbucket is not None and bucket[1] != lastbucket[1]: 156 | print() 157 | print('%3d %2d %s%s' % (bucket[1], bucket[2]//4, ' '.join([fmt(counter[c], s) for c in classes]), u)) 158 | lastbucket = bucket 159 | 160 | 161 | 162 | 163 | --------------------------------------------------------------------------------