はじめに
Day 2では、Electron + TypeScript + FastAPIという技術スタックを選んだ理由について書きました。
今回はバックエンドの最初の機能として実装した、時間記録機能の設計について書きます。
シンプルに見えて、設計の判断が積み重なった部分です。
タイマー機能とは
今回のツールにおける最初の機能として、作業時間の記録と管理を実装しました。
一般的に言う打刻管理と同じものをイメージしていただくとわかりやすいかと思います。
この機能を実装した理由は2つあります。
1つは、今後開発するNN機能との連携です。打刻時間とタスク情報をNNに渡して推論し、マイルストーンやプロジェクトの完成時期予測を行う機能を実装する予定です。そのための学習データとして、日付・時間の記録が必要になります。
もう1つは、個人開発者が自身の作業時間を客観視できる情報を提示することで、自己管理をしやすくするためです。
保存形式にCSVを選んだ理由
保存形式はCSVを採用しています。記録するカラムは以下の通りです。
| カラム名 | 内容 |
|---|---|
| start-date | 開始日 |
| start-time | 開始時刻 |
| end-date | 終了日 |
| end-time | 終了時刻 |
| total | 作業時間の合計 |
CSVを選んだ主な理由は、NN機能で読み込む際に「いつ・どの程度の時間・何のタスクに関する作業をしたか」を明示的に渡すことで、予測結果の精度を上げることを意識したからです。また、このカラム設計であれば日付をまたいだ作業も記録できます。深夜に作業を開始して翌日の朝に終了した場合でも、start-dateとend-dateを別々に持つため記録が崩れません。
タスク情報はJSONLで管理していますが、CSVとJSONLを使い分けているのは用途の違いによるものです。時間記録のような単純な行追記にはCSVが適しており、タスクのような構造を持つデータにはJSONLが適していると判断しました。
バリデーションチェック
バリデーションは、同じボタンを2回押した際の誤操作を防ぐために実装しました。flagでボタンの状態を記憶し、これを元にチェックを行っています。
class TimeCheker():
def __init__(self):
self.flag = False
def start_checker(self):
if self.flag is True:
return "終了ボタンが押されていません"
else:
start_time = datetime.now()
self.start = start_time
self.flag = True
return start_time
def end_checker(self):
if self.flag is False:
return "開始ボタンが押されていません"
else:
self.endtime = datetime.now()
self.total = self.endtime - self.start
result = self.save_to_csv()
self.flag = False
return result
flagがFalseの状態で開始ボタンが押されると、時刻を記録してflagをTrueにします。flagがTrueのまま再度開始ボタンが押された場合はエラーを返します。終了ボタンも同様の逆の判定を行っています。
このバリデーションはフロントエンドとバックエンドの両方に実装しています。フロントだけに置くと、APIを直接叩かれた際にバリデーションをすり抜けてしまうため、バックエンド側でも同じチェックを行うことで、どこから操作されても同じ動作を保証できます。
保存先のパス指定について
保存先はOSのユーザーデータ領域に設定しています。パス指定の処理はcreate_dirメソッドに分離し、timer側でインポートして使用しています。
def create_dir(file):
target_dir = 'milestone_manager/'
# os judgement
if platform.system() == 'Linux':
base_path = os.path.join(pathlib.Path.home(), '.local/share/')
if platform.system() == 'Windows':
env = os.environ.get('APPDATA')
base_path = pathlib.Path(env)
if platform.system() == 'Darwin':
base_path = os.path.join(pathlib.Path.home(), 'Library/Application Support/')
if not os.path.exists(os.path.join(base_path, target_dir)):
os.mkdir(os.path.join(base_path, target_dir))
file_path = pathlib.Path(os.path.join(base_path, os.path.join(target_dir, file)))
return file_path
platform.system()でOSを判定し、それぞれの標準的なユーザーデータ領域に保存先を振り分けています。
| OS | 保存先 |
|---|---|
| Linux | ~/.local/share/milestone_manager/ |
| Windows | %APPDATA%\milestone_manager\ |
| macOS | ~/Library/Application Support/milestone_manager/ |
Windowsのみos.environ.get('APPDATA')で環境変数から直接パスを取得しています。%APPDATA%はシェルが展開する記法のためPythonから直接パスとして使用できず、環境変数経由で取得する形にしています。
パス指定の失敗による損失
最初はリポジトリ直下にデータを配置していました。
この段階で.gitignoreでデータファイルを管理から外していましたが、git pullを実行した際にこれまでの記録が全て消えてしまう事態が発生しました。git pullはリポジトリ直下の内容も更新してしまうため、.gitignoreで管理対象外にしていてもディレクトリごと巻き込まれてCSVが消えました。
この失敗から、問題の本質は「外部操作によるデータへの干渉」だと判断しました。OSが管理するユーザーデータ領域であれば、git pullはその領域に干渉しません。かつ使用時にユーザーが環境変数を設定する必要もありません。この2つの条件を満たす保存先として現在のパスを採用しました。
自動ディレクトリ生成について
このツールで重視しているのは、使用者に負担をかけないことです。
先のcreate_dirと合わせ、データファイルは指定のディレクトリに自動生成される仕様にしています。誰がどのOSで使用しても、保存先を手動で作成する必要はありません。
def save_to_csv(self):
year_month = datetime.now().strftime("%Y-%m")
path = f"csv/time_{year_month}.csv"
full_path = create_dir(path)
if not os.path.exists(create_dir('csv/')):
os.mkdir(create_dir('csv/'))
file = os.path.isfile(full_path)
try:
with open(full_path, 'a', encoding="utf-8") as f:
writer = csv.writer(f)
if not file:
writer.writerow(["start-date", "start-time","end-date", "end-time","total"])
start_date = self.start.strftime("%Y-%m-%d")
start_time = self.start.strftime("%H:%M:%S")
end_date = self.endtime.strftime("%Y-%m-%d")
end_time = self.endtime.strftime("%H:%M:%S")
duration = str(self.total).split(".")[0]
data = [start_date ,start_time, end_date, end_time ,duration]
writer.writerow(data)
return str(self.total).split(".")[0]
except Exception as e:
print(f"エラー発生->{e}")
return "csv保存に問題発生"
ファイルが存在しない場合のみヘッダー行を書き込み、以降は追記モード('a')で記録します。月別にファイルが分かれる(time_2026-05.csvなど)ため、長期間使用しても1ファイルが肥大化しない設計になっています。
なお、データを格納するディレクトリはLinuxでは隠しディレクトリ(.local以下)になります。Windowsの%APPDATA%・macOSの~/Library/も通常のファイルブラウザからは直接見えにくい領域です。このため、今後データの取り出し機能を追加する方針です。
おわりに
今日は時間記録機能の設計について書きました。
シンプルな機能に見えて、フラグ管理・バリデーションの二重配置・保存先の設計と、判断が積み重なった部分でした。特に保存先のパス設計は実際にデータを消す失敗をしてから変えた経緯があります。同じ轍を踏む方が減れば幸いです。
Day 4では、タスク管理のデータ構造について書く予定です。JSONLを選んだ理由・UUIDによるタスク識別・ステータス管理の設計など、goals.pyの設計判断を記録します。
リポジトリはOSS公開準備中です。公開後にこの記事へリンクを追加します。
この記事は連載「クラウドに依存しないマイルストーン管理ツール開発記」のDay 3です。