⏱️

【Obsidian】Dataviewを使って毎日の工数をタグごとに集計する

2024/01/23に公開

はじめに

私は Obsidian のコミュニティプラグイン Day Planner を利用して、日々のスケジュールを管理しています。

Day Planner はデイリーノートのタスクに時間を追加することで、タスクのスケジュールをタイムラインで表示できます。タイムラインからタスクを追加することもできます。
ラフに予定や作業時間を記録することができ、重宝しています。

ただ、Day Planner にはタスクにかかった時間の集計機能がありません。
日々の工数をつける際、手動で集計していたので時間がかかっていました。

そこで、Dataview プラグインを使って、タスクにかかった時間を自動で集計するようにしてみました。

工数集計の実装方法

工数を集計するために、デイリーノート内のタスクにタグを付け、タグごとの合計時間と総計を表示させる方法を採用しました。

注意点としては以下の通りです。

  • タグ
    • タスクには1つのみタグを付けるようにしています。
      • 複数付けると管理が煩雑になるため、1つのみを想定しています。
      • 複数のタグがある場合、2つ目以降は無視されます。
      • タグが付いていないタスクは集計されません。
  • 時刻
    • 時刻のフォーマットは HH:mm もしくは HH:mm-HH:mmHH:mm - HH:mm を想定しています。
    • Day Planner と同じように、次のタスクの開始時間は、前のタスクの終了時間となります。
    • 最後のタスクがタグ付けされており、終了時刻が指定されていなかった場合、24時までタスクが続くと解釈します。
      • 終了時刻の記入漏れを防ぐため、このような仕様としています。
    • 24時を超えるタスクについては、動作を保証していません。

手順

  1. Obsidian Vault に JavaScript ファイルを追加する

    以下のコードを含む JavaScript ファイルを Vault 内に追加します。

    長いので折りたたみます
    aggregate-day-planner.js
    /** 指定ファイルのタスクの所要時間をタグごとに集計するスクリプト */
    
    // タスクから時刻を抽出するための正規表現
    const timeRegex = /^(\d+:\d+)(\s*-(\d+:\d+))?/;
    
    // dv.viewの引数inputからファイルパスを取得する
    const filePath = input.filePath;
    showDurations(filePath);
    
    /**
     * 各タスクの所要時間を集計して表示する
     * @param {string} filePath 集計するファイルのパス
     */
    function showDurations(filePath) {
      const durations = aggregateTaskDurations(filePath);
      const tableContent = generateTableContent(durations);
      dv.table(...tableContent);
    }
    
    /**
     * 各タスクの所要時間を集計する
     * @param {string} filePath 集計するファイルのパス
     * @returns {Object} 各タグの所要時間を格納したオブジェクト
     */
    function aggregateTaskDurations(filePath) {
      const tasks = dv
        .page(filePath)
        .file.tasks.filter((task) => timeRegex.test(task.text));
      const durations = {};
    
      for (let i = 0; i < tasks.length - 1; i++) {
        parseTask(durations, tasks[i], tasks[i + 1]);
      }
      if (tasks.length > 0) {
        parseTask(durations, tasks[tasks.length - 1], null);
      }
      return durations;
    }
    
    /**
     * タスクの所要時間を集計する
     * @param {Object} durations 各タグの所要時間を格納したオブジェクト
     * @param {Object} currentTask 現在のタスク
     * @param {Object} nextTask 次のタスク
     * @returns {Object} 各タグの所要時間を格納したオブジェクト
     */
    function parseTask(durations, currentTask, nextTask) {
      let { startTime, endTime } = extractTimeFromTask(currentTask);
      if (!endTime) {
        endTime = extractTimeFromTask(nextTask).startTime || parseTime("24:00");
      }
    
      if (!currentTask.tags.length) {
        return;
      }
      const tag = currentTask.tags[0];
      if (!durations[tag]) {
        durations[tag] = 0;
      }
      durations[tag] += endTime - startTime;
    }
    
    /**
     * タスクから所要時間(ミリ秒)を抽出する
     * @param {*} task タスク
     * @returns {Object} タスクの開始時間と終了時間を格納したオブジェクト
     */
    function extractTimeFromTask(task) {
      if (!task) {
        return { startTime: null, endTime: null };
      }
      const match = task.text.match(timeRegex);
      if (!match) {
        return { startTime: null, endTime: null };
      }
    
      const startTime = parseTime(match[1]);
      const endTime = match[3] ? parseTime(match[3]) : null;
    
      return { startTime, endTime };
    }
    
    /**
     * 時刻文字列をミリ秒に変換する
     * @param {string} time 時刻文字列
     * @returns {number} 時刻のミリ秒
     */
    function parseTime(time) {
      return Date.parse(`01/01/2000 ${time}`);
    }
    
    /**
     * テーブルの内容を生成する
     * @param {Object} durations 各タグの所要時間を格納したオブジェクト
     * @returns {Array} テーブルの内容
     */
    function generateTableContent(durations) {
      const totalMs = Object.values(durations).reduce(
        (prev, curr) => prev + curr,
        0
      );
      const tableHeader = ["Task", "Time"];
      const tableContent = Object.entries(durations)
        .sort((a, b) => b[1] - a[1])
        .map(([tag, duration]) => [tag, getTimeStringFromMs(duration)]);
      tableContent.push(["**Total**", getTimeStringFromMs(totalMs)]);
      return [tableHeader, tableContent];
    }
    
    /**
     * 所要時間を文字列に変換する
     * @param {number} duration ミリ秒
     * @returns {string} HH:mm形式の文字列
     */
    function getTimeStringFromMs(duration) {
      const hours = Math.floor(duration / 1000 / 60 / 60);
      const minutes = Math.floor(duration / 1000 / 60) % 60;
      return `${hours}:${String(minutes).padStart(2, "0")}`;
    }
    

    コードが行っていることは以下です。

    1. タスクの抽出と時間の解析
      指定されたファイルのタスクを抽出し、それぞれのタスクの文字列から開始時間と終了時間を解析します。
    2. タグごとの時間集計
      タスクに付けられたタグ、開始時間と終了時間を元に、タグごとに時間を集計します。
    3. 合計時間の計算と表示
      タグごとの時間をすべて合計して総計を求めます。
      dv.table() を使い、テーブル形式でタグごとの合計時間、総計を表示します。
  2. デイリーノートのテンプレートに、先程追加した JavaScript ファイルを読み込むコードブロックを追加する

    ```dataviewjs
    await dv.view('scripts/aggregate-day-planner', { filePath: dv.current().file.path })
    ```

    このコードブロックは、指定された JavaScript ファイルをビューとして実行するためのものです。
    dv.view() を使うことで JavaScript ファイルをビューとして実行できます。
    第1引数に実行する JavaScript ファイルのパス、第2引数に JavaScript に渡したい引数をオブジェクトで指定します。
    第1引数は、上に挙げたコードでは scripts/aggregate-day-planner.js もしくは scripts/aggregate-day-planner/view.js が読み込まれます。
    第2引数は JavaScript 側で input として受け取ることができます。

終わりに

この方法を使うことで、日々の工数集計が楽になりました。

今回はタグごとの集計を実装しましたが、タスクの文字列から工数をカテゴリ分けするなど、他にも集計方法はあると思います。
この記事を見た方はぜひ色々なアイデアを試してみてください。

Discussion