日報作成＆工数出力のGUIアプリつくってみた

ジョブカン事業部のアドベントカレンダー9日目の記事です！

https://qiita.com/advent-calendar/2024/jobcan

<br></br>
ジョブカン事業部で開発を担当している @Larvesta636 と申します。
むし・ほのおタイプです🐛🔥

<br></br>
今回の記事では、最近個人で作成した **日報作成と工数出力ができるGUIアプリ** についてご紹介します。

# TL;DR
- Python の 標準ライブラリ tkinter を使ってGUIアプリを作成した
- 作業内容を簡単に作成・登録でき、日報として保存・コピーできる
- 保存した作業履歴から作業時間を計算し、月次スプレッドシートへ貼り付け可能な形式で出力できる

# 背景
ジョブカン事業部では、終業時にその日の作業内容を日報としてメールで送信しています。
有志の方が作成した便利な送信スクリプトがあるため、作業内容を入力するだけで送信が完了します。

```text:日報のイメージ
    お疲れ様です。〇〇です。
    本日の業務報告をいたします。
    
    ＜本日の業務内容＞
    ・issue対応(4h)
    ・コードレビュー(2h)
    ・実装相談のMTG(1h)
    ・朝会/夕会(1h)
      ...
```

また、月末には各作業の工数をまとめ、決められたフォーマットでスプレッドシートに入力する作業があります。
これは開発工数をプロジェクト別に集計するための資料になります。

スプレッドシートのイメージ：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918025/1ce8943b-c044-56a0-9c6d-ac6dd8a867a0.png)



これら2つの作業は一見別々のようですが、実際には密接に関係しており(?)、以下のような手間がかかっています。 ~~毎日コツコツ入力すればいいのでは？~~

- 出勤日分の日報を確認し、スプレッドシートに入力する必要がある
- 作業をカテゴリ別に分類する必要がある
- 日報とスプレッドシートのフォーマットが異なるため、自分で変換が必要
    - 日報：`hh.hh`（例: 7.5h）
    - スプレッドシート：`hh:mm:ss`（例: 07:30:00）
- Googleカレンダーと連携可能だが、フォーマット変換は手作業

<br></br>
そんな煩雑さに悩んでいたとき、心の中にいる「エンジニアのマリー・アントワネット」がこう囁きました。

<br></br>
<br></br>
### 💃「解決するものがなければ、自分でつくればいいじゃない」

<br></br>
<br></br>
### つくりました。

<br></br>
# 必要な機能を考える
開発言語は、業務で使用しているPythonを選択。
GUIアプリケーションにはPythonの標準ライブラリ「Tkinter」を採用しました。

以下は、アプリ作成にあたり優先度順に整理した機能のリストです。

### これがないと始まらない（Must）
- 作業内容を決まったフォーマットで入力可能
- 入力した作業内容をクリップボードにコピー可能
- 作業内容を日付ごとに保存
- 工数のスプレッドシート用にテキスト出力

### できれば欲しい（Should）
- 土日祝を考慮して工数を計算
- 保存ファイルを年月ごとに管理 
- 入力内容のクリア機能
- タスクの編集・更新機能

### あったら嬉しい（Want）
- 一日の合計作業時間を表示
- テンプレートタスクの登録・呼び出し
- 設定ファイルの追加
- タスクの並び替え

<br></br> 
またREADMEについてはChatGPTに~~書かせ~~手伝ってもらいました 🙌

# 実装してみた

### 動作環境
Python 3.11.9

### ファイル構成

```:daily_apps
daily_apps/
│
├── gui.py              # GUI構築
├── daily_apps.py       # アプリのエントリーポイント
├── report_manager.py   # 日報データ管理
├── text_processor.py   # データ処理
└── utils.py            # ユーティリティ            
```


#### daily_apps.py
アプリ全体の起動ポイントです。Tkinterで画面を作成します。

<details><summary>コード</summary>

```python:daily_apps.py
"""
daily_apps.py

このモジュールは、日報作成アプリケーションのエントリーポイントです。
Tkinterを使用してGUIを作成し、ReportAppクラスを初期化してメインループを開始します。

このスクリプトを実行すると、日報作成アプリが起動します。
"""

from tkinter import Tk
from gui import ReportApp

if __name__ == "__main__":
    root = Tk()
    app = ReportApp(root)
    root.mainloop()


```

</details>

#### gui.py
作業を入力する画面を作ります。日報の保存やコピーができます。

<details><summary>コード</summary>

```python:gui.py
import json
import os
import tkinter as tk
from tkinter import ttk, messagebox, StringVar
from tkcalendar import DateEntry
from report_manager import ReportManager
from text_processor import process_text
from datetime import datetime

default_task_categories = [
    "つくる",
    "なおす",
    "まもる",
    "せいりする",
    "そのた",
]


class ReportApp:
    """
    日報作成アプリのGUIクラス。

    このクラスはTkinterを用いて、日報を作成するためのインターフェースを提供します。
    """

    def __init__(self, root):
        """
        クラスの初期化メソッド。

        Args:
            root (tk.Tk): Tkinterのルートウィンドウ。
        """
        self.root = root
        self.root.title("日報作成アプリ")
        self.report_manager = ReportManager()
        # テンプレートタスクを保持するdict
        self.templates = {}
        # タスク履歴データを保持するlist
        self.history_data = []

        # フォントサイズを設定
        default_font = ("Arial", 12)
        self.root.option_add("*Font", default_font)

        # 日付選択フレーム
        self.date_frame = tk.Frame(root)
        self.date_frame.pack(anchor="w", pady=10)
        self.label_date = tk.Label(self.date_frame, text="日付:")
        self.label_date.grid(row=0, column=0, sticky="w")

        self.date_var = StringVar()
        self.date_entry = DateEntry(
            self.date_frame,
            textvariable=self.date_var,
            date_pattern="yyyy-mm-dd",
            mindate=datetime(2023, 1, 1),
            maxdate=datetime.now(),
        )
        self.date_entry.set_date(datetime.now())
        self.date_entry.grid(row=0, column=1, padx=10)

        # テンプレートタスクのプルダウンフレームを追加
        self.template_frame = tk.Frame(root)
        self.template_frame.pack(pady=10)

        # テンプレートラベルを追加
        self.label_template = tk.Label(self.template_frame, text="テンプレートタスク:")
        self.label_template.grid(row=0, column=0)

        self.template_var = StringVar()
        self.template_dropdown = ttk.Combobox(
            self.template_frame, textvariable=self.template_var, width=30
        )
        self.template_dropdown.grid(row=1, column=0)
        self.template_dropdown["state"] = "disabled"

        self.add_template_button = tk.Button(
            self.template_frame, text="追加", command=self.add_template_task
        )
        self.add_template_button.grid(row=1, column=1, padx=5)
        self.add_template_button["state"] = "disabled"
        # テンプレートを読み込む
        self.load_templates()

        # 入力フレーム
        self.input_frame = tk.Frame(root)
        self.input_frame.pack(pady=10)

        # プルダウン、テキストボックス、数値入力、作成ボタン
        self.label_task = tk.Label(self.input_frame, text="タスク分類:")
        self.label_task.grid(row=0, column=0, padx=5)
        self.task_var = StringVar()
        self.task_dropdown = ttk.Combobox(
            self.input_frame,
            textvariable=self.task_var,
            width=15,
            state="readonly",
        )
        self.task_dropdown.grid(row=1, column=0, padx=5)

        # settings.jsonを読み込む
        self.load_settings()

        self.label_description = tk.Label(self.input_frame, text="内容:")
        self.label_description.grid(row=0, column=1, padx=5)
        self.description_var = StringVar()
        self.text_description = tk.Entry(
            self.input_frame, textvariable=self.description_var, width=30
        )
        self.text_description.grid(row=1, column=1, padx=5)

        self.label_hours = tk.Label(self.input_frame, text="作業時間(h):")
        self.label_hours.grid(row=0, column=2, padx=5)
        self.hours_var = tk.DoubleVar(value=0)
        self.spin_hours = tk.Spinbox(
            self.input_frame,
            from_=0,
            to=24,
            increment=0.5,
            textvariable=self.hours_var,
            width=8,
        )
        self.spin_hours.grid(row=1, column=2, padx=5)

        self.submit_button = tk.Button(
            self.input_frame, text="作成", command=self.submit_report
        )
        self.submit_button.grid(row=1, column=3, padx=5)

        # 合計時間を表示するフレームを追加
        self.total_hours_frame = tk.Frame(root)
        self.total_hours_frame.pack(pady=5)

        # 合計時間ラベルを太文字で追加
        self.total_hours_label = tk.Label(
            self.total_hours_frame, text="合計時間: 0.0h", font=("Arial", 12, "bold")
        )
        self.total_hours_label.pack()

        self.history = tk.Listbox(root, height=10, width=80)
        self.history.pack()
        self.history.bind(
            "<Double-1>", self.on_task_double_click
        )  # ダブルクリックでタスクを選択
        self.history.bind("<Button-1>", self.on_task_press)  # マウスボタン押下イベント
        self.history.bind("<B1-Motion>", self.on_task_drag)  # マウスドラッグイベント
        self.history.bind("<ButtonRelease-1>", self.on_task_drop)  # ボタン離しイベント

        # フィールドの初期化
        self.dragged_index = None
        self.selected_task_index = None  # 選択されたタスクのインデックスを初期化

        # 保存ボタンとコピーボタンを配置するフレーム
        self.button_frame = tk.Frame(root)
        self.button_frame.pack(pady=10)

        # コピーボタン
        self.copy_button = tk.Button(
            self.button_frame, text="コピー", command=self.copy_history
        )
        self.copy_button.grid(row=0, column=0, padx=5)

        # 保存ボタン
        self.save_button = tk.Button(
            self.button_frame, text="保存", command=self.save_reports
        )
        self.save_button.grid(row=0, column=1, padx=5)

        # クリア
        self.clear_button = tk.Button(
            self.button_frame, text="クリア", command=self.reset_all
        )
        self.clear_button.grid(row=0, column=2, padx=5)

        # 工数出力ボタン
        self.process_button = tk.Button(
            root, text="工数出力", command=self.process_reports
        )
        self.process_button.pack(pady=10)

    def on_task_select(self, event):
        selected_index = self.history.curselection()
        if selected_index:
            index = selected_index[0]
            task_info = self.history_data[index]  # 直接取得
            self.task_var.set(task_info["task"])
            self.description_var.set(task_info["description"])
            self.hours_var.set(task_info["hours"])
            self.submit_button.config(text="更新")
            self.selected_task_index = index  # インデックスを保存
        else:
            self.clear_inputs()  # 選択解除時にクリア
            self.selected_task_index = None

    def on_task_double_click(self, event):
        """タスクがダブルクリックされたときの処理。選択されたタスクを編集する。"""
        selected_index = self.history.curselection()
        if selected_index:
            index = selected_index[0]
            if self.selected_task_index == index:
                # すでに選択されているタスクを再度ダブルクリックした場合、選択解除
                self.clear_inputs()  # 入力フィールドをクリア
                self.selected_task_index = None  # 選択解除
                self.submit_button.config(text="作成")  # ボタンを元に戻す
            else:
                # 新しいタスクを選択
                self.on_task_select(event)  # タスクを選択状態にする
        else:
            self.clear_inputs()  # 選択解除時にクリア
            self.submit_button.config(text="作成")  # ボタンを元に戻す

    def on_task_press(self, event):
        """タスクが押されたときの処理。"""
        self.dragged_index = self.history.nearest(
            event.y
        )  # 押されたタスクのインデックスを取得

    def on_task_drag(self, event):
        """タスクがドラッグされているときの処理。"""
        if self.dragged_index is not None and self.dragged_index >= 0:
            self.history.itemconfig(
                self.dragged_index, {"bg": "#d3d3d3"}
            )  # ドラッグ中のタスクの色を変更

    def on_task_drop(self, event):
        """タスクがドロップされたときの処理。"""
        if self.dragged_index is not None:
            target_index = self.history.nearest(
                event.y
            )  # ドロップ先のインデックスを取得
            if target_index != self.dragged_index:
                # タスクを入れ替える
                task_info = self.history_data[self.dragged_index]
                del self.history_data[self.dragged_index]  # 元の位置から削除
                self.history_data.insert(target_index, task_info)  # 新しい位置に挿入

                # リストボックスを更新
                self.history.delete(0, tk.END)  # 全削除
                for item in self.history_data:
                    history_str = (
                        f"【{item['task']}】{item['description']}({item['hours']}h)"
                    )
                    self.history.insert(tk.END, history_str)  # 新しい順序で挿入

            self.dragged_index = None  # リセット

    def load_templates(self):
        """
        テンプレートタスクを設定ファイルから読み込む。
        """
        try:
            with open("template_task.json", "r", encoding="utf-8") as f:
                templates = json.load(f)
                # 選択済みフラグを追加
                self.templates = {
                    i: {**t, "selected": False} for i, t in enumerate(templates)
                }

                formatted_templates = [
                    f"{t['description']} ({t['hours']}h)" for t in templates
                ]

                self.template_dropdown["values"] = formatted_templates
                self.template_dropdown["state"] = "normal"
                self.add_template_button["state"] = "normal"
        except FileNotFoundError:
            # テンプレートファイルが見つからない場合、プレースホルダーを設定
            self.template_dropdown["state"] = "disabled"
            self.add_template_button["state"] = "disabled"
        except json.JSONDecodeError:
            # JSON読み込みエラーの場合、エラーメッセージを表示
            messagebox.showerror(
                "JSONエラー", "テンプレートファイルの読み込みに失敗しました。"
            )

    def add_template_task(self):
        """
        選択されたテンプレートタスクを履歴に追加する。
        """
        # 選択されたテンプレートタスクのインデックスを取得
        selected_index = self.template_dropdown.current()
        # タスク登録なしでないことを確認
        if selected_index >= 0:
            task_info = self.templates[selected_index]

            if not task_info["selected"]:
                hours = task_info["hours"]
                description = task_info["description"]
                task = task_info["task"]
                report = {
                    "task": task,
                    "description": description,
                    "hours": float(hours),
                }
                self.history_data.append(report)
                history_str = f"【{task}】{description}({hours}h)"
                self.history.insert(tk.END, history_str)

                # 合計時間の更新
                self.update_total_hours(hours)

                # 選択したテンプレートタスクのフラグを更新
                self.templates[selected_index]["selected"] = True
            else:
                # 既に選択済みの場合
                messagebox.showwarning(
                    "選択エラー", "このテンプレートタスクは既に追加されています。"
                )
        else:
            # 選択されていない場合の処理
            messagebox.showwarning(
                "選択エラー", "テンプレートタスクが選択されていません。"
            )

    def load_settings(self):
        """
        settings.json から設定を読み込む。
        """
        try:
            with open("settings.json", "r", encoding="utf-8") as f:
                settings = json.load(f)
                # タスク分類の設定
                self.task_categories = settings.get(
                    "task_categories", default_task_categories
                )
                self.task_dropdown["values"] = self.task_categories
                # コピー時のフォーマットを設定
                self.copy_format = settings.get(
                    "copy_format", "【{task}】{description}({hours}h)"
                )
        except FileNotFoundError:
            messagebox.showerror(
                "設定ファイルエラー",
                "settings.json が見つかりません。デフォルト設定を使用します。",
            )
        except json.JSONDecodeError:
            messagebox.showerror(
                "設定ファイルエラー", "settings.json の読み込みに失敗しました。"
            )

    def submit_report(self):
        """
        入力された報告を履歴に追加する。

        入力フィールドからタスク、内容、作業時間を取得し、履歴リストに追加する。
        フィールドが未入力の場合は警告メッセージを表示する。
        """
        task = self.task_var.get()
        description = self.description_var.get().strip()
        hours = self.hours_var.get()

        if task and description and hours:
            report = {
                "task": task,
                "description": description,
                "hours": float(hours),
            }
            if (
                hasattr(self, "selected_task_index")
                and self.selected_task_index is not None
            ):
                self.history_data[self.selected_task_index] = report  # 更新
                self.history.delete(self.selected_task_index)  # リストボックスから削除
                history_str = f"【{task}】{description}({hours}h)"
                self.history.insert(
                    self.selected_task_index,
                    history_str,
                )  # 同じ位置に挿入
                self.selected_task_index = None  # インデックス削除
                self.submit_button.config(text="送信")  # ボタンを送信に戻す
            else:
                self.history_data.append(report)  # 新規追加
                history_str = f"【{task}】{description}({hours}h)"
                self.history.insert(tk.END, history_str)

            self.clear_inputs()
            # 合計時間を更新
            self.update_total_hours(hours)
        else:
            messagebox.showwarning("入力エラー", "すべてのフィールドを入力してください")

    def update_total_hours(self, hours):
        """
        合計時間を更新する。
        """
        current_total = float(self.total_hours_label.cget("text").split(": ")[1][:-1])
        new_total = current_total + hours
        self.total_hours_label.config(text=f"合計時間: {new_total:.1f}h")

    def save_reports(self):
        """
        現在の履歴を保存する。

        日付と履歴のデータを取得し、ReportManagerを使用して保存する。
        データが空の場合は警告メッセージを表示する。
        """
        date = self.date_entry.get()

        if not self.history_data:
            messagebox.showwarning("保存エラー", "保存するデータがありません")
            return

        self.report_manager.save_reports(date, self.history_data, overwrite=True)
        messagebox.showinfo("保存成功", "日報が保存されました。")

    def process_reports(self):
        """
        保存された報告を処理する。

        選択された日付に基づいてレポートをロードし、テキスト処理を行う。
        処理が完了した後にメッセージボックスで通知する。
        """
        year_month = self.date_entry.get()[:7].replace("-", "")
        reports = self.report_manager.load_reports(year_month)

        if not reports:
            messagebox.showwarning(
                "ファイルエラー", f"{year_month} のデータが存在しません。"
            )
            return

        for date, report_list in reports.items():
            output_with_date, output_without_date, errors = process_text(reports)
            self.write_results(
                year_month, output_with_date, output_without_date, errors
            )

        messagebox.showinfo("処理完了", "工数出力が完了しました。")

    def copy_history(self):
        """
        履歴をクリップボードにコピーする。

        現在の履歴リストの内容をクリップボードにコピーし、完了メッセージを表示する。
        """
        if not self.history_data:
            messagebox.showwarning("コピーエラー", "コピーするデータがありません")
            return

        history_text = "\n".join(
            self.copy_format.format(
                task=entry["task"],
                description=entry["description"],
                hours=entry["hours"],
            )
            for entry in self.history_data
        )
        self.root.clipboard_clear()
        self.root.clipboard_append(history_text)
        messagebox.showinfo("コピー完了", "履歴がクリップボードにコピーされました")

    def clear_inputs(self):
        """
        入力フィールドをクリアする。

        タスク、内容、作業時間の入力フィールドを初期状態に戻す。
        """
        self.task_var.set("")
        self.description_var.set("")
        self.spin_hours.delete(0, tk.END)
        self.spin_hours.insert(0, "0.0")

    def write_results(self, year_month, output_with_date, output_without_date, errors):
        """
        テキスト処理の結果をテキストファイルに書き込む。

        Args:
            year_month (str): 年月（形式: YYYYMM）。
            output_with_date (list): 日付と合計を含むデータ。
            output_without_date (list): 日付と合計を含まないデータ。
            errors (list): エラーメッセージのリスト。
        """

        # 保存ファイル および 保存ファイル名の設定
        save_dir = "saved_reports"
        os.makedirs(save_dir, exist_ok=True)
        processed_file_name = f"{save_dir}/daily_to_workload_{year_month}.txt"

        try:
            with open(processed_file_name, "w", encoding="utf-8") as file:
                file.write("===== 項目「日付」、「合計」を含むデータ（確認用） =====\n")
                header = "日付, " + ", ".join(self.task_categories) + ", 合計\n\n"
                file.write(header)
                for line in output_with_date:
                    file.write(line + "\n")

                file.write(
                    "\n\n===== 項目「日付」、「合計」を含まないデータ（コピペ用） =====\n"
                )
                for line in output_without_date:
                    file.write(line + "\n")

                if errors:
                    file.write(
                        "\n\n===== フォーマットに沿っていないタスク（修正用） =====\n"
                    )
                    for error in errors:
                        file.write(error + "\n")
        except Exception as e:
            messagebox.showerror(
                "書き込みエラー", f"結果の書き込み中にエラーが発生しました: {e}"
            )

    def reset_all(self):
        """
        入力フィールドと選択項目をクリアする。

        タスク分類、内容、作業時間、日付選択などをリセットします。
        """
        # 入力フィールドのリセット
        self.task_var.set("")  # タスク分類の選択をリセット
        self.description_var.set("")  # 内容の入力をリセット
        self.hours_var.set(0)  # 作業時間の入力をリセット
        self.date_var.set("")  # 日付のリセット

        # 履歴選択のリセット（履歴リストボックスの選択解除）
        self.history.selection_clear(0, tk.END)  # 履歴の選択を解除

        # タスク履歴のリセット（Listboxの内容をすべて削除）
        self.history.delete(0, tk.END)  # 履歴の内容を全て削除
        
        # テンプレートタスクの使用フラグをリセット
        for key in self.templates:
            self.templates[key]["selected"] = False 

        # history_dataをリセット
        self.history_data.clear()  # 履歴データもリセット

        # 合計時間のリセット
        self.total_hours_label.config(text="合計時間: 0.0h")

```

</details>

#### report_manager.py
日報データを保存・読み込みする機能を持っています。

<details><summary>コード</summary>

```python:report_manager.py
import json
import os
from datetime import datetime


class ReportManager:
    def __init__(self, report_folder="saved_reports"):
        self.report_folder = report_folder
        os.makedirs(self.report_folder, exist_ok=True)

    def save_reports(self, date, reports, overwrite=False):
        """
        報告を指定されたファイルに保存する。

        Args:
            date (str): 日付。
            reports (list): 保存する報告のリスト（辞書形式）。
            overwrite (bool): 同じ日付があれば上書きするかどうか。
        """
        year_month = datetime.strptime(date, "%Y-%m-%d").strftime("%Y%m")
        file_name = os.path.join(self.report_folder, f"saved_reports_{year_month}.json")

        # ファイルが存在する場合、読み込む
        if os.path.exists(file_name):
            with open(file_name, "r", encoding="utf-8") as f:
                try:
                    data = json.load(f)
                except json.JSONDecodeError as e:
                    print(f"Error reading JSON: {e}")
                    data = {}
        else:
            data = {}

        # 同じ日付があれば上書き、なければ追加
        if overwrite:
            data[date] = reports
        else:
            if date not in data:
                data[date] = []

            for report in reports:
                task, description, hours = self.parse_report(report)
                data[date].append(
                    {"task": task, "description": description, "hours": hours}
                )

        # 保存処理
        with open(file_name, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=4)

    def load_reports(self, year_month):
        """
        指定された年月の報告を読み込む。

        Args:
            year_month (str): 年月（形式: YYYYMM）。

        Returns:
            dict: 読み込まれた報告の辞書。
        """
        file_name = os.path.join(self.report_folder, f"saved_reports_{year_month}.json")
        if os.path.exists(file_name):
            with open(file_name, "r", encoding="utf-8") as f:
                try:
                    return json.load(f)
                except json.JSONDecodeError as e:
                    print(f"Error reading JSON: {e}")
        return {}

    def parse_report(self, report):
        """
        タスクデータを解析して、タスク分類、内容、作業時間を抽出する。

        Args:
            report (dict): タスクデータ（辞書形式）。

        Returns:
            tuple: タスク分類、内容、作業時間。
        """
        task = report["task"]
        description = report["description"]
        hours = report["hours"]

        return task, description, hours

```

</details>

#### text_processor.py
日報データを整理して工数計算を行います。

<details><summary>コード</summary>

```py
import json
from datetime import datetime
from utils import convert_to_hhmm, get_holidays, get_all_dates


default_task_categories = [
    "つくる",
    "なおす",
    "まもる",
    "せいりする",
    "そのた",
]


def load_settings():
    """設定ファイルを読み込む。"""
    with open("settings.json", "r", encoding="utf-8") as f:
        return json.load(f)


def get_weekday_string(date):
    """
    日付から曜日を取得する。

    Args:
        date (datetime): 曜日を取得する日付オブジェクト。

    Returns:
        str: 曜日（例: '月', '火', '水', '木', '金', '土', '日'）。
    """
    weekday_mapping = ["月", "火", "水", "木", "金", "土", "日"]
    return weekday_mapping[date.weekday()]


def process_text(reports_dict):
    """
    日報データを処理し、集計結果を生成する。

    Args:
        reports_dict (dict): 日報データの辞書。日付をキーとし、各日付の報告リストを値とする。

    Returns:
        tuple:
            - list: 日付と合計を含むデータのリスト。
            - list: 日付と合計を含まないデータのリスト。
            - list: エラーメッセージのリスト。
    """
    settings = load_settings()
    task_categories = settings.get("task_categories", default_task_categories)
    category_mapping = {
        category: index for index, category in enumerate(task_categories)
    }

    year_month = list(reports_dict.keys())[0][:7]
    year, month = map(int, year_month.split("-"))
    weekends, holidays = get_holidays(year, month)  # 土日と祝日を取得
    all_dates = get_all_dates(year, month)  # 全ての日付を取得

    aggregated_hours = {date: [0] * len(category_mapping) for date in all_dates}
    errors = []

    for date, report_list in reports_dict.items():
        for report in report_list:
            task = report["task"]
            hours = report["hours"]
            if task in category_mapping:
                aggregated_hours[date][category_mapping[task]] += hours
            else:
                errors.append(f"不明なタスク: {task}")

    result_with_date = []
    result_without_date = []

    for date_str in all_dates:
        date = datetime.strptime(date_str, "%Y-%m-%d")
        weekday = get_weekday_string(date)  # 曜日を取得

        if date_str in holidays:
            result_with_date.append(f"{date_str} ({weekday}・祝),")  # 祝日を表示
            result_without_date.append("")
            continue
        elif date_str in weekends:  # 土日の場合
            result_with_date.append(f"{date_str} ({weekday}),")  # データは0で表示
            result_without_date.append("")
            continue

        hours = aggregated_hours[date_str]
        total_hours = sum(hours)
        total_hours_hhmm = convert_to_hhmm(total_hours)
        hours_hhmm = [convert_to_hhmm(h) for h in hours]  # 各タスクの時間も変換

        result_with_date.append(
            f"{date_str} ({weekday}), {', '.join(hours_hhmm)}, {total_hours_hhmm}"
        )
        result_without_date.append(", ".join(hours_hhmm))

    return result_with_date, result_without_date, errors

```

</details>

#### utils.py
時間変換や休日判定などの機能を提供します。
<details><summary>コード</summary>

```py
from datetime import datetime, timedelta
import jpholiday


def convert_to_hhmm(hours):
    """
    時間をHH:MM形式に変換する。

    Args:
        hours (float): 変換する時間（小数形式）。

    Returns:
        str: HH:MM形式の文字列。エラーが発生した場合は"00:00"を返す。
    """
    try:
        h = int(hours)
        m = int((hours - h) * 60)
        return f"{h:02}:{m:02}"
    except Exception as e:
        return "00:00"


def get_holidays(year, month):
    """
    指定された月の土日と祝日をリストで取得する。

    Args:
        year (int): 年（例: 2024）。
        month (int): 月（1〜12）。

    Returns:
        tuple:
            - list: 土日のリスト（形式: YYYY-MM-DD）。
            - list: 祝日のリスト（形式: YYYY-MM-DD）。
    """
    start_date = datetime(year, month, 1)
    end_date = (start_date + timedelta(days=31)).replace(day=1) - timedelta(days=1)

    weekends = []
    holidays = []

    for single_date in (
        start_date + timedelta(n) for n in range((end_date - start_date).days + 1)
    ):
        if single_date.weekday() >= 5:  # 土曜日または日曜日
            weekends.append(single_date.strftime("%Y-%m-%d"))
        if jpholiday.is_holiday(single_date):  # 祝日
            holidays.append(single_date.strftime("%Y-%m-%d"))

    return weekends, holidays


def get_all_dates(year, month):
    """
    指定された月の全ての日付をリストで取得する。

    Args:
        year (int): 年（例: 2024）。
        month (int): 月（1〜12）。

    Returns:
        list: 指定された月の全ての日付（形式: YYYY-MM-DD）のリスト。
    """
    start_date = datetime(year, month, 1)
    end_date = (start_date + timedelta(days=31)).replace(day=1) - timedelta(days=1)

    all_dates = []
    for single_date in (
        start_date + timedelta(n) for n in range((end_date - start_date).days + 1)
    ):
        all_dates.append(single_date.strftime("%Y-%m-%d"))

    return all_dates

```

</details>

#### オプションの設定ファイル

<details><summary>コード</summary>

```json:settings.json
{
    "task_categories": [
        "つくる",
        "なおす",
        "まもる",
        "せいりする",
        "そのた"
    ],
    "copy_format": "【{task}】 {description} ({hours}h)"
}

```

```json:template_task.json
[
    {
        "task": "そのた",
        "description": "あさかい・ゆうかい",
        "hours": 0.5
    }
]


```

</details>

# 挙動を見てみよう
まず以下のコマンドでアプリを起動します。
```bash
python daily_apps.py
```

こちらが今回作成したGUIアプリです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918025/ab2434b0-2002-fb68-e0bb-b8ad3378a0c5.png)

このように作業内容を入力し、保存やコピーができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918025/b413488d-29d1-625b-b74b-ea22eb80a112.png)


```:コピーした日報
【つくる】 すごいきのうのじっそう (4.0h)
【なおす】 わるいところをただす (3.5h)
【そのた】 あさかい・ゆうかい (0.5h)
```

そして「工数出力」ボタンを押すと `daily_to_workload_{yyyymm}.txt` が生成されます。
これには以下のように工数出力されてます。

```text:daily_to_workload_{yyyymm}.txt
===== 項目「ひづけ」、「ごうけい」を含むデータ（確認用） =====
ひづけ, つくる, なおす, まもる, せいりする, そのた, ごうけい

2024-12-01 (日),
2024-12-02 (月), 04:00, 03:30, 00:00, 00:00, 00:30, 08:00
2024-12-03 (火), 00:00, 01:00, 05:00, 00:00, 00:30, 06:30
2024-12-04 (水), 00:00, 00:00, 00:00, 00:00, 00:00, 00:00
2024-12-05 (木), 00:00, 00:00, 00:00, 00:00, 00:00, 00:00
2024-12-06 (金), 00:00, 00:00, 00:00, 00:00, 00:00, 00:00
2024-12-07 (土),
2024-12-08 (日),
...

===== 項目「ひづけ」、「ごうけい」を含まないデータ（コピペ用） =====

04:00, 03:30, 00:00, 00:00, 00:30
00:00, 01:00, 05:00, 00:00, 00:30
05:00, 00:00, 00:00, 02:00, 00:30
00:00, 03:00, 03:00, 02:00, 00:30
06:00, 00:00, 01:00, 00:00, 01:00


...
```

この「コピペ用」テキストをスプレッドシートにコピー＆ペーストし、「テキストを列に分割」を実行すると、なんときれいに入力できたではありませんか！ウオー！！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918025/b6f4b779-705d-8fd5-074e-da73164246b2.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918025/684eab92-b050-54bd-aad9-fcbd76317e35.png)


その他にも紹介できていない機能がありますので、気になった方は実際に動かしてみてください！（？）

# 終わりに
GUIアプリを完走した感想ですが、開発中にアクシデントが多発し、思ったよりも時間がかかりました。
機能ごとに開発してマージするとデグレが発生したり、実際に使ってみて不具合が見つかったりと、なかなか苦労しました。
それでも、最初に思い描いていた理想的な動作を実現できるアプリに仕上がったのは良かったと思います。
実際に毎日使ってみて、普通に便利で楽ですね。
とりあえず一旦リファクタリングしようと思います🦋

<br></br>
#### Special Thanks
β版を使用してフィードバックしてくれた @djwq さん（アドカレ10日目担当！！）


# おまけ
私も所属するジョブカン事業部では現在積極的に採用活動を行っています。
もし興味を持っていただけた方、あるいは業務中に本記事の日報作成アプリを使ってみたい方(?)は、ぜひご検討ください。

<br></br>

https://www.donuts.ne.jp/recruit/

https://recruit.jobcan.jp/donuts/show/B001/1396297