ローカル環境の勤怠管理アプリを作ってみた

Qiita記事初投稿です、よろしくお願いします。

# エレベーターピッチ
**リモートワークで勤怠管理を自分でしなくてはならないが、めんどくさいから自動化したいリモートワーカー** 向けの、**デスクトップ常駐型アプリ Shift Recorder(仮)** というプロダクトは、**Mac向け勤怠管理自動化アプリ**である。このアプリはMacのメニューバー常駐アプリであるため意外と目立つ。勤務中はデスクトップ上でアイコンの色が変わるため、勤務中ということが一目で確認でき、リモートワークならではの、他のことに意識を削がれるという生産性低下の原因を削減できる。加えて、**GASを用いたSlackなどでの勤怠管理とは違って、ローカル環境で動作する。何度も言うが、勤務中ということが一目でわかる**ことが本プロダクトの一番の特徴である。

# 動機
堅苦しい商品紹介から始まったが、売り込みではないので安心して欲しい...
このアプリを作ろうと思ったのはリモートワークをするようになり、勤怠管理が自分の裁量になったことに起因します。どうしても正確性に欠けてしまうし、なんといってもいちいち時間を確認するのが面倒臭いし、月末勤務時間を計算するもの面倒臭い。どうにかしようと行き着いたのは、

「よし、自動化しよう」

ということでした。
GASを使ったSlackで勤怠管理もできるが、企業のSlackに導入するのも気が引けるし、一目で勤務中とわかるようなものが欲しかったので自作することにしました。

# 筆者について
こういったアプリ開発系(プログラミング)の記事を読むとき作成者のレベルがわからないことに不便を感じているので、本記事では少し筆者について触れます。(執筆時点2022/11)

職業：大学学部生3年
プログラミング歴：
	Python歴：1年と3ヶ月(継続)
	Java Script：1ヶ月(一期間だけ)
	SQL：半年(継続)
Mind：自動化して楽したい

Python歴1年ちょっとの人が作るアプリなので非効率なことにはご容赦を...

# 機能
完成形
![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2540179/29a52ac2-91dc-4910-0524-cfd2397bf332.gif)

## 使用シチュエーション
今回作成したデスクトップ常駐アプリについて、使用シチュエーションをいくつか紹介します。

「よし、仕事しよう！」(本アプリ開始ボタンポチッ)

「あ、アイコン緑だから仕事中だ」(デスクトップの本アプリアイコンを見る)

「どれくらい時間経ったのかな？」(本アプリの経過時間表示を見る)
「2時間だ」

「一旦終わるか、」(本アプリ終了ボタンポチッ)
「今回はバグ直したぞ」(表示されるウィンドウに実施事項を記録する)

「仕事仕事〜」(本アプリ開始ボタンポチッ)
「あ、今日休みだった」(本アプリ取り消しボタンポチッ)

「出勤簿見よ〜」(本アプリ出力ボタンポチッ→Excelファイルがダウンロードされる)

こんなことができます。

## こだわり
本アプリでこだわった部分があるので紹介する。
(こだわったというか欲しかった機能みたいなとこはある)

- 簡単に開始/終了を記録できる、具体的には2ポチで完結する
- 記録中は一目でわかるようにアイコンの色が変わる
- 経過時間が見れる
- 記録を出力できる(今月と前月分までカバー)

(こういった自分の欲しい機能を盛り込めるのが独自開発の楽しい点ですよね)

# 環境
注意：Mac向けのアプリです

```python
Python : 3.9.10
rumps : 0.4.0
pandas : 1.3.4
numpy : 1.21.1
dateutil : 2.8.2
```
＊記載がないものは標準搭載のもので、Pythonのバージョンに準じます。

バージョン確認方法()
```python
import rumps
rumps.__version__
##'0.4.0'
```
＊`__version__` は一部のモジュールで搭載されている

今回の目玉であるRumpsについては、[公式リファレンス](https://rumps.readthedocs.io/en/latest/index.html)を参照してください。

# 実装

## ディレクトリ構造
ディレクトリの構造は以下のようになっています。
shift_recorder
　 |-images
　 |　　-(アイコン画像はここに)
　 |-datas
　 |　　-(ここにアウトプットされます)
　 |-scripts
　　　 　|-main.py
 　　　 　|-record.py
 　　　　 |-download.py
 　　　　 |-makeworklist.py

## 実装にあたって
最終的に.pyファイルにしますが、作成時点ではjupyterを使った方がやりやすいと思います。

## 実装練習(rumpsについて)
では、rumpsで作っていきましょう。
まず、見た目だけ作ってそれから機能を付与する方針でいきます。

まず、開始と終了だけ表示してみます。
```python:main.py
from rumps import *

class RumpsTest(App):
	def __init__(self):
		super(RumpsTest, self).__init__("shift_recorder")
		self.menu=[
				MenuItem("開始"),
				MenuItem("終了"),
				MenuItem("取り消し")
				]

if __name__ == "__main__":
	app = RumpsTest()
	app.run()
```

![スクリーンショット 2022-11-15 12.48.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2540179/41eec07e-bcbf-d58d-1698-5a849631a3f9.png)
＜解説＞
RumpsTestというクラスを作成して、その中に記載していきます。
最後の部分で、呼び出しています。

`def __init__(self)`の中でアプリ全体の設定をします。将来的にデフォルト表示アイコンや「quit」の変更などもこの関数内に記述します。
menu属性にMenuItem型で項目をリスト形式で書くと、表示されます。今はまだ機能を付与していないので半透明で表示されます。
他にも表示させる方法はありますが、今回は省略します。


それでは、項目に機能を付与してみましょう。機能付与は関数を書いて項目に紐づけるだけです。とりあえずprint関数を書いておきます
```python:main.py
from rumps import *

class RumpsTest(App):
	def __init__(self):
		super(RumpsTest, self).__init__("shift_recorder")
		self.menu=[
				MenuItem("開始",callback=self.start),
				MenuItem("終了",callback=self.end),
				MenuItem("取り消し")
				]
	
	def start(self,sender):
		print("開始しました。")

	def end(self,sender):
		print("終了しました。")

if __name__ == "__main__":
	app = RumpsTest()
	app.run()
```

![スクリーンショット 2022-11-15 13.12.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2540179/f3ac7dc7-fc1b-0236-a490-dd1df5f0c79b.png)
＜解説＞
どうでしょう、開始と終了の文字が黒く表示されて反応するようになりましたか？
それぞれの関数に渡してる引数ですが、アプリ情報が格納されているものだと思ってください、使うとき使います。
この関数の紐付けは項目を設定した部分にcallbackを指定してあげるだけです(selfを忘れないようにしましょう)

これだけでアプリな感じが出てきたと思います。(?)


この後使うテクニックを先に紹介しておきます。
上記の状態を例に扱います。

### 画像をメニューバーに表示する。
```python
class RumpsTest(App):
	def __init__(self):
		super(RumpsTest,self).__init__("shift_recorder",icon="images/icon.png")
	...
```
imagesファイルにあるpngファイルを指定してあげることで、画像が表示されるようになります。これから私は スプラトゥーン のイカをアイコンにしていきます。

![スクリーンショット 2022-11-15 13.11.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2540179/c8621741-c53f-d936-196a-987b9f6cc5e6.png)

### 紐付けの上書き
たとえば、開始ボタンを押したら開始ボタンを不活にしたい場合(2度目押しを防ぐために)は上書きで紐付けをなくす(空で上書き)することで解決できます
```python
class RumpsTest(App):
	...
	def start(self,sender):
		print("開始")
		self.menu["開始"].set_callback(None)
	...
```
上記のように項目を指定できてset_callbackで上書きできます。別の関数を指定することもできます。その時は`self.関数名`にすることを忘れないように！

### アイコンの変更
本アプリの目玉である、「仕事中はアイコンに色がついて仕事中であることが一目でわかる」機能はこれで実装できます。
```python:main.py
class RumpsTest(App):
	...
	def start(self,sender):
		print("開始")
		self.icon="images/icon2.png"
	...
```
これだけで変更(上書き)できます。

### 項目名の変更
後々、経過時間表示とかで項目名を変更したくなります。その方法は次の通りです。
```python:main.py
class RumpsTest(App):
	...
	def start(self,sender):
		print("開始")
		self.menu["開始"].title="仕事中"
	...
```
これで開始を押したら「仕事中」になると思います。
ただ、注意が必要なのが表示名が変わっているだけで内部的には開始という項目です。なので再び表示名を変えようとするときは注意が必要です。

```python
self.menu["開始"].title="仕事中"
の後、再び変更するときは、
self.menu["開始"].title="仕事終わりそう"
と、「開始」を指定してください。
```

### 項目の階層化
項目を階層化する方法を紹介します。
menuにadd関数で項目を加えるだけです。
```python
from rumps import *

class RumpsTest(App):
	def __init__(self):
		super(RumpsTest, self).__init__("shift_recorder")
		self.menu=[
				MenuItem("開始",callback=self.start),
				MenuItem("終了",callback=self.end),
				MenuItem("取り消し")
				]
		self.menu["開始"].add(MenuItem("開始1"))
		self.menu["開始"].add(MenuItem("開始2"))
	...
```

![スクリーンショット 2022-11-15 14.45.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2540179/ed65161c-4679-0979-350d-3d443ebca846.png)

### コメント入力ボックスの表示
実施内容も記録しておきたいので入力ボックスウィンドウを終了ボタンを押したら表示するようにします。
このウィンドウもrumpsで出せます。(有能すぎ)
```python
class RumpsTest(App):
	...
	def end(self,sender):
		print("終了")
		response = Window(message="Feed back?",dimensions=(300,200)).run()
		print(response.text)
	...
```
message：表示テキスト
dimensions：ウィンドウサイズ

response.textで入力したテキストを取得できます。

### タイマー
説明を省略した項目の表示の仕方ではタイマーはアプリを起動した時から動きます。今回は開始を押したタイミングで記録を開始したいため、start関数にタイマーを盛り込みたいと思います。タイマーをrumpsで実装できます。(有能)
```python
class RumpsTest(App):
	...
	def start(self,sender):
		print("開始")
		global my_timer,count
		count = 0
		my_timer = Timer(self.pass_time,1)
	
	def pass_time(self,sender):
		global count
		print(count)
		count += 1
	...
```
start関数外でもタイマーを操作したいのでglobal変数にします。
Timerの引数について、第一引数に指定時間ごとに呼び出す関数を指定します。第二引数にはその指定時間(秒)を指定します。
今回はpass_timeという関数を作って時間を記録していきます。

(countの値を項目名に変更することで経過時間を表示できますね)

### yes/noウィンドウの表示
項目をクリックした時、注意が入ると嬉しいですよね？誤タップ防止になりますね？
rumpsでできます。
```python
alert("取り消しますか？",ok="はい",cancel="いいえ")
```
このように書くと、以下のように表示が出ます。

![スクリーンショット 2022-11-15 13.45.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2540179/64f3081a-8a3d-15a9-5509-3aaa5872a4f7.png)

これを組み込んで、yesの時にstart関数が動くようにしましょう。
```python
class RumpsTest(App):
	...
	def start(self,sender):
		if alert("取り消しますか？",ok="はい",cancel="いいえ"):
			print("開始")
			##表示の変更やタイマーのスタート
		else:
			print("いいえが押されました。")
	...
```

## rumps外のコードについて
項目をクリックして呼び出す関数はrumpsの枠から離れてあらゆる処理を実行可能です。開始時間の記録、終了時間の記録、出力など本アプリでもいろんな機能を搭載しています。
記録する関数や出力する関数について用意していきます。

### record.py
開始・終了の記録は開始か終了かを引数に渡して、時間の記録などは全て関数内で完結するようにしました。(終了の場合はテキストも与えるので第二引数にそれを入れてます。)

月毎でcsvファイルを作成して記録していきます。呼ばれるたび該当月のcsvファイルを読み込んで追記処理をします。

データはID,Activity,TimeStamp,Feed backから成るようにします。(プロセスマイニングのデータ形式を真似て)
```python:record.py
from datetime import datetime
import pandas as pd
import numpy as np
import os

def record(activity,text=""):
	time_now = datetime.now()
	date = time_now.strftime("%Y-%m")
	#該当月のcsvファイルを読み込む
	try:
		#日付の月のファイルを開く
		df = pd.read_csv("datas/"+date+".csv")
	except:
		# なかったら作成する
		df = pd.DataFrame(columns=["ID","Activity","Time stamp","Feed back"])
		#datasファイルがない場合は作成する。ある場合は作成しない。
		os.makedirs("datas",exist_ok=True)

	#IDの決定
	if activity =="開始":
		#開始の場合は新規IDを割り振る
		id = int(len(df)+1)
	else:
		#開始以外はIDは割り振らず、後に開始と同じIDで補完する
		id = np.nan
	
	#DataFrameに追記する処理
	df = df.append({"ID":id,
					"Activity":activity,
					"Time stamp":datetime.now(),
					"Feed back":text},
					ignore_index=True)

	#IDの補完処理
	df["ID"] = df["ID"].interpolate("ffill")

	#csvファイルの書き出し
	df.to_csv("datas/"+date+".csv",index=False)
```

### 出力
実行した日付(月)のファイルを出力する機能だけでは月初に前月分のデータが欲しい時に困るな、と思ったので前月と今月分のデータを出力できるようにチューニングしました。(引数に今月分or前月分かを示すiを追加、前月なら1を入れて月から1引く)

出力形式は、日付、曜日、開始時間、終了時間、勤務時間、フィードバック　をレコードにします。
ここら辺はケースによって異なると思うので適当に。
```python:makeworklist.py
import pandas as pd
import datetime
import dateutil

def make_work_list(i=0):
	#月の確認
	month = datetime.datetime.today()
	#前月分を出力したい場合(i=1)のための処理、差をとる
	month = month - dateutil.relativedelta.relativedelta(months=i)
	#整形
	month = month.strftime("%Y-%m")

	#対象ファイルを指定
	try:
		df = pd.read_csv("datas/"+month+".csv")
	except:
		print("ファイルがありませんでした")
		return
  
	#出力用DataFrame
	result = pd.DataFrame(columns=["day","start","end","feedback"])

	#ファイルの整形
	df["Time stamp"] = pd.to_datetime(df["Time stamp"].str[:16])

	#ユニークなcase_idを抽出
	case_ids = df["ID"].unique()

	#集計
	for case_id in case_ids:
		#取り消しがあったら記録しない
		if "取り消し" in list(df[df["ID"]==case_id]["Activity"]):
			continue

		#TimeStampが汚いので複雑なコードになってます
		result = result.append({
			"day":str(df[df["ID"]==case_id][df["Activity"]=="開始"]["Time stamp"].iloc[-1])[:10],
			"start":str(df[df["ID"]==case_id][df["Activity"]=="開始"]["Time stamp"].iloc[-1])[-8:],
			"end":str(df[df["ID"]==case_id][df["Activity"]=="終了"]["Time stamp"].iloc[-1])[-8:],
			"feedback":str(df[df["ID"]==case_id][df["Activity"]=="終了"]["Feed back"].iloc[-1])}
			,ignore_index=True)

	#差分計算
	result["time(min)"] = (pd.to_datetime(result["end"]) - pd.to_datetime(result["start"])).dt.total_seconds()
	#分表示に
	result["time(min)"] = result["time(min)"]/60
	#曜日計算
	result["date"] = pd.to_datetime(result["day"]).dt.day_name()
	#表示順変更
	result = result.reindex(columns=["day","date","start","end","time(min)","feedback"])

	#出力
	result.to_excel("/Users/user_name.Downloads/{}_出勤簿.xlsx".format(month))
```

# 本実装
下準備を長く書いてしまいましたが、ここからアプリ本体です。
モジュールとは別に上で作成した、download.pyとmakeworklist.pyを使用するため所定の場所に入れておいてくださいね。

分割して解説していきます。
## 1.ライブラリのインポート
```python:main.py
from rumps import *
from record import *
import download,datetime,makeworklist
```

## 2.アプリの諸設定
```python:main.py
class RumpsTest(App):
	def __init__(self):
		super(RumpsTest, self).__init__("shift_recorder",icon="../images/black.png", title=None,quit_button="shift_recorderの終了")
		#デフォルトのメニュー作成
		self.menu=[
					MenuItem("開始",callback=self.start),
					MenuItem("終了"),
					MenuItem("取り消し"),
					MenuItem("経過時間"),
					None,
					MenuItem("出勤簿の出力"),
					None,
					MenuItem("環境設定"),
					MenuItem("詳細")]
		self.menu["出勤簿の出力"].add(MenuItem("今月",callback=self.make_work_list))
		self.menu["出勤簿の出力"].add(MenuItem("先月",callback=self.make_last_work_list))
```
＜解説＞
`icon`は黒いものをデフォルトに指定しています。
`quit_button = "shift_recorderの終了"`はメニューの一番したにあるquitの文字列を変更しています。デフォルトではquitです。
項目は以上の通りで(一部見た目をよくしたいがために入れています。機能しません。)、Noneを挟むことで区切り線を入れることができます。
出勤簿の出力に関しては今月分と先月分を分けたいので階層構造にしています。
階層構造にせず、「今月の出勤簿の出力」、「先月の出勤簿の出力」とトップに並べてもいいかもしれません。

最初の関数の紐付けは、開始と二つ出力だけです。(この先定義します)

## 3.開始の関数(start)
```python:main.py
def start(self,sender):
	#記録開始
	record("開始","")
	#タイマー起動
	global my_timer,count
	count=0
	my_timer=Timer(self.pass_time,1)
	my_timer.start()
	#メニューの更新
	self.menu["開始"].set_callback(None)
	self.menu["終了"].set_callback(self.end)
	self.menu["取り消し"].set_callback(self.cancel)
	#アイコンに色をつける
	self.icon="images/green.png"
```
＊インデントに注意してください

＜解説＞
自分で書いたrecord.pyの関数を呼び出して記録しています。また、タイマーを開始しています。(pass_timeは後に記述します)
開始したら終了ボタンと取り消しボタンがアクティブになって開始ボタンは不活化するようにしましょう。
紐付けてる関数は後に記述します。
**仕事中はアイコンの色を変えましょう**

## 4.終了の関数(end)
```python:main.py
def end(self,sender):
	#タイマー停止
	my_timer.stop()
	
	#メニュー更新
	self.menu["終了"].set_callback(None)
	self.menu["開始"].set_callback(self.start)
	self.menu["取り消し"].set_callback(None)
	self.menu["経過時間"].title="経過時間"

	#アイコンの更新
	self.icon="images/pink.png"
	
	#入力フィールドの表示
	response = Window(message="Feed back?",dimensions=(300,200)).run()

	#記録
	record("終了",response.text)

	#アイコンをデフォルトのものに変更
	self.icon="images/black.png"
```
＜解説＞
開始した後にアクティブになる終了ボタンの処理です。
まずタイマーを止めてます。そしてメニューの更新です。終了と取り消しが不活化して開始が再びアクティブになります。
この時点ではまだ実装していませんが、「経過時間」の項目は横に時間が表示されていたのでクリアします。

入力フィールドを出しますが、この時のアイコンは赤にしています。(まだ終わっていないことを示すため)
入力したらそのテキストを持って記録します。

## 5.経過時間の表示(pass_time)
```python:main.py
def pass_time(self,sender):
	global count
	#カウントを1進めます
	count = count+1

	#時間の表示形式を変更
	pass_time = datetime.timedelta(seconds=int(count)+1)

	#経過時間項目の横に時間を表示する
	self.menu["経過時間"].title="経過時間:"+str(pass_time)
```
＜解説＞
ここは特に解説はないですね、

## 取り消しの処理(cancel)
```python:main.py
def cancel(self,sender):
	if alert("取り消しますか？",ok="はい",cancel="いいえ"):
		#取り消しの記録
		record("取り消し","")
		#タイマーの停止
		my_timer.stop()
		#表示項目の更新
		self.menu["終了"].set_callback(None)
		self.menu["開始"].set_callback(self.start)
		self.menu["取り消し"].set_callback(None)
		self.menu["経過時間"].title="経過時間"
		#アイコンの更新
		self.icon="../images/black.png"
```
＜解説＞
間違って開始を押してしまった時に止めるための項目です。
誤タップを防ぐためalsertを挟んでいます。
あとは終了の時と同じ処理です。

## 出力(make_work_list)
```python:main.py
def make_work_list(self,sender):
	makeworklist.make_work_list()

def make_last_work_list(self,sender):
	makeworklist.make_work_list(1)
```
＜解説＞
残り二つは一緒に解説します。
この二つは出勤簿に出力するための関数を含んでいます。
今月分と先月分を分けるために引数を与えたため分けています。

## おまじない
```python:main.py
if __name__ == "__main__":
	app = RumpsTest()
	app.run()
```
＜解説＞
作ったクラスでappを作って走らせましょう。


以上が本アプリのコードになります。
このあとはpy2appでアプリに整形して常駐させましょう。
## py2appでアプリ化
Macでアプリに出力するならpy2appが便利だと思います。pythonではないのでターミナルから操作します。
[このサイト](https://y-naito.ddo.jp/index.php?id=1477186158)が参考になると思います。

setup.pyの内容が躓きやすいと思うので紹介します。
```python:setup.py
"""
This is a setup.py script generated by py2applet
Usage:
python setup.py py2app
"""
from setuptools import setup

APP = ['main.py']#アプリの名前

DATA_FILES = [
			  "images/green.png",
			  "images/pink.png",
			  "images/black.png",]
OPTIONS = {
		   "argv_emulation":False,
		   "iconfile":"images/アイコン.icns",
		   "strip":True,
		   "includes":["download","record"]
		   }

setup(
app=APP,
data_files=DATA_FILES,
options={'py2app': OPTIONS},
setup_requires=['py2app'],
)
```
＜解説＞
APPはアプリの名前です。ご自由にどうぞ。
DATA_FILESはコード内で使った写真などを格納してください。
OPTIONSは写してもらえばいいんですが、iconfileでアイコンの見た目(メニューバーのではなくDocsに表示されるアイコン)、includesはしようしたライブラリを記述します。書かなくてもいいらしいですが、自分で書いたものは一応記述しておきました。
アプリにする時エイリアスモード(-Aを指定する)があると思うのですが、個人の範囲で使用するならエイリアスモードで不便ないと思います。

ちなみにMacの右側メニューバーに表示されているアイコンたちはCommandおしながらドラッグすることで並び替えることができますよ。

# 展望
勤怠管理を自動化させようと思いついた当初のイメージからは違ってしまいましたが(tkinterを使ったアプリに初期はしようとしていた)、rumpsを使ったこの形に満足しています。
さて、一応完成はしたわけですがまだ改善の余地がたくさんあります。まず見た目のためだけに入れた環境設定などの項目を有効化したいですね。
また、構想当初は「休憩」「再開」の項目も視野に入れていたのですが出勤簿にする際に休憩込みで勤務表に起こす仕組みを作れなかったので断念しました。いつか実現したいと思います。
そして一番優先度が高いのが切り替え機能です。現状一つの仕事しか記録できませんが、これを仕事A、仕事B、読書などのパターンを切り変えられるようになったらもっと便利になると思っています。この切り替えを環境設定に盛り込めたらなと思っています。
あと(キリがないw)、出勤簿のexcelファイルは書式設定何もしていないのでもっと見やすくしときたいですね。
細かいところでは、開始したら音がなるようにしたり、出力の時に音がなるようにできたらなと思ってます(アイコンスプラトゥーンにしたのでそれ関連の音？)

# 終わりに
展望の部分でだいぶ思いの丈を述べてしまいましたが、今回理想としてるアプリを作成できて自分的には満足です。
アプリ開発してる時だったり、この記事を書いてる時は楽しくて時間を忘れてしまいます。
(記事執筆も一気に書いているのですが、10時に始めたのに気づいたら16時です。w)
今まで3つくらい独自でアプリを作っていますが、Qiitaの記事に起こすのは初めてです。個人的に成長を感じられて嬉しいです。

拙いコードや解説だと思いますがここまで読んでいただきありがとうございました。質問がございましたらお手柔らかにお願いします。

# 参考文献
https://qiita.com/takahiro1112/items/7da5db8bea89b16a6625
https://qiita.com/tokky08/items/67bd547e7ff3c4dc9d70
https://rcmdnk.com/blog/2015/11/16/computer-mac-python/
https://blog.i-tale.jp/2020/11/08/