時間記録をCSVで行う理由 - Day 9
はじめに
Day 8では、データ保存先の設計を3回変えることになった経緯について書きました。
今回は時間記録の保存形式にCSVを採用した理由と、フォーマット設計の判断について書きます。
なぜCSVを選んだか
時間記録の保存形式にCSVを採用した理由は、NN機能での読み込みに適していると判断したためです。
CSVの特徴は、1行目にヘッダーを定義し2行目以降にデータを入れる構造です。NN機能で読み込む際に、1行目をヘッダーとして取り出すことで各列の属性を取得し、以降の行がデータであると認識させやすい構造になっています。
タスク管理にはJSONLを採用していますが、時間記録は単純な行追記で完結するためCSVの方が適していると判断しました。用途に応じてファイル形式を使い分けている理由はDay 3・Day 4で詳しく書いています。
データベースを使用しない理由
ローカルツールであってもSQLiteなどのデータベースをパッケージに含むことは可能です。しかし今回は以下の2点から採用を見送りました。
1つは、バイナリサイズを小さく保つためです。データベースエンジンをパッケージに含めることは依存関係の増加を意味します。今回の用途では過剰と判断しました。
もう1つは、NN機能での読み込みを想定した場合にCSVとJSONLの方が扱いやすいためです。ファイルをそのまま渡せる形式の方が実装の負荷を下げられます。
CSVのフォーマット設計
保存するカラムは以下の通りです。
| カラム名 | フォーマット | 内容 |
|---|---|---|
| start-date | YYYY-MM-DD | 開始日 |
| start-time | HH:MM:SS | 開始時刻 |
| end-date | YYYY-MM-DD | 終了日 |
| end-time | HH:MM:SS | 終了時刻 |
| total | HH:MM:SS | 作業時間の合計 |
日付と時刻をカラムとして分離しているのは、日付をまたいだ作業でも記録が崩れないようにするためです。深夜に作業を開始して翌日の朝に終了した場合でも、start-dateとend-dateを別々に持つため正確に記録できます。
この構造であれば終了日時から開始日時を差し引くだけで正確な稼働時間を計算できます。
実装は以下の通りです。
def save_to_csv(self):
year_month = datetime.now().strftime("%Y-%m")
path = f"csv/time_{year_month}.csv"
full_path = create_path(path)
if not os.path.exists(create_path('csv/')):
os.mkdir(create_path('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:
return f"Problems occurred with saving the CSV file.{e}"
strftimeのフォーマット指定は"%Y-%m-%d"と"%H:%M:%S"で統一しています。日付・時刻を明示的に分けて指定することで、カラムと対応した値が確実に保存されます。
月別ファイルの自動生成
保存ファイルは月ごとに自動生成されます。ファイル名はtime_2026-05.csvのように年月で命名されるため、長期間使用しても1ファイルが肥大化しません。
NN機能や他の機能でデータを参照する際も、年月を指定してファイルを絞り込める設計になっています。当日分のデータを取り出す実装は以下の通りです。
def import_to_csv(self):
year = datetime.now().strftime('%Y')
month = datetime.now().strftime('%m')
data = FileSearch(year=year, month=month).reard_to_csv()
times = data[1:]
target = datetime.now().strftime("%Y-%m-%d")
return_data = [row for row in times if row[0] == target]
return return_data
FileSearchクラスで月単位のCSVを全件取得し、その後当日の日付でフィルタリングしています。読み込みモジュールは全件を返す設計とし、フィルタリングは呼び出し側で行う方針です。FileSearchクラスの設計についてはDay 10で詳しく書く予定です。
フロントエンド側での表示
取得したCSVデータはtimer.tsでテーブル形式に描画しています。
private async _csv_log(){
const csv_data = (await api.timer.todayTimerTimerTodayGet()).data
this.csv_body.innerHTML = ''
csv_data.forEach((row: String) => {
const tr = document.createElement('tr')
const data1 = row[1]
const data3 = row[3]
const data4 = row[4]
const td1 = document.createElement('td')
td1.classList.add('td1')
const td3 = document.createElement('td')
td3.classList.add('td3')
const td4 = document.createElement('td')
td4.classList.add('td4')
td1.textContent = data1 +' / '
td3.textContent = data3 +' / '
td4.textContent = data4
tr.appendChild(td1)
tr.appendChild(td3)
tr.appendChild(td4)
this.csv_body.appendChild(tr)
});
}
表示するのはstart-time・end-time・totalの3カラムのみです。日付カラムは当日分のみを表示する仕様のため除外しています。インデックス指定(row[1]・row[3]・row[4])でカラムを取り出しているのは、CSVの列順がカラム定義と対応しているためです。
おわりに
今日は時間記録のCSVフォーマット設計について書きました。
シンプルな構造ですが、日付をまたいだ記録への対応・月別ファイル分割・NN機能での読み込みを見据えた設計判断が積み重なっています。
Day 10ではFileSearchクラスの設計について書く予定です。NN機能を見据えた年月単位での読み込み設計と、goals.pyからの機能移植の経緯を記録します。
リポジトリはOSS公開準備中です。公開後にこの記事へリンクを追加します。
この記事は連載「クラウドに依存しないマイルストーン管理ツール開発記」のDay 9です。
Discussion