GoogleスプレッドシートとGASでストップウオッチつきタスク管理ツールをつくってみた

はじめに

タスク管理ってみなさんどうされてますか?

わたしはずっと悩んでいて、いろいろアプリを使っていましたが、ピンとくるものがなく…

パソコンがmacでスマホがiphoneということもあり、最近は純正のリマインダーアプリに落ち着いていました。

しかし、生活のタスクと勉強のタスクをリマインダーの日時指定で管理すると、重要な生活のタスクが埋もれてしまうという問題がありました。

リマインダーは生活のタスク用にして、勉強のタスクを管理する別のアプリでいいものはないかな…

求めるものとしては次のとおりでした。

  1. チェックリスト形式になっている
  2. 勉強時間を計測できる
  3. 勉強の記録がログとして保存できる(できたらスケジュールに載せたい)

1,2はそれぞれ実現できるアプリはあるのですが、両方備えたものが存在しませんでした。

3については、純正リマインダーを使うとスケジュールアプリMocaにその日期限のリマインダーを表示させることができるのですが、タスクを完了してしまうと表示が消えてしまいます。

1,2,3すべてを兼ね備えたアプリを見つけられなかったので、GoogleスプレッドシートのToDoテンプレートをベースにして、GAS (Google Apps Script)というローコードツールでプログラミングしてアプリを自作することにしました。

GAS (Google Apps Script)とは?

公式ページから引用します。

Apps Script は、Google Workspace の統合、自動化、拡張のためのビジネス ソリューションをすばやく簡単に構築するための唯一のローコード プラットフォームです。Apps Script を使えば、ビジネス ユーザーは本格的なソフトウェア開発の経験がなくても、Google Workspace 上にカスタム ソリューションを構築できます。Apps Script は、Gmail アカウントをお持ちであればどなたでもご利用いただけます。

https://workspace.google.co.jp/intl/ja/products/apps-script/

簡単にいうと、スプレッドシート、ドキュメント、スライド、カレンダー、メール、フォームなどのGoogleのアプリケーションにおいて、プログラムを書くことで連携させたり、トリガーで自動化したり、自分の望む処理を実現したりできるプラットフォームのことです。

GASはJavaScriptベースの言語ですが、Googleのアプリケーションのための組み込み関数が定義されていて、簡単にコーディングすることができます(JavaScriptの組み込み関数で使えないものもあります)。

カレンダーやメール、スプレッドシートなどの機能を自分で一から作るのは大変ですが、GASを使うと既存のサービスの上に機能を載せることができます。

詳細については公式ページhttps://www.google.com/script/start/や、以下の本を参考にしてください。

Google Apps Script目的別リファレンス 実践サンプルコード付き 第3版

用途向けにサンプルコードが載っているので、使い道がイメージしやすいです。

詳解! Google Apps Script完全入門 [第3版]

こちらは辞書的に使うのがおすすめ。

GASを使うにはどうしたらいいの?

GASはスタンドアロン型とコンテナバインド型がありますが、今回はスプレッドシートに直接紐づけるコンテナバインド型で実装していきます。

まずはGoogleアカウントにログインして、スプレッドシートを開いてください。

上部タブの「拡張機能」を選択すると、「Apps Script」が出てくるのでこれを選択します。

するとエディタが立ち上がります。

なにか処理を記述するとフロッピーボタン(保存)がアクティブになり、保存できるようになります。最初は保存しないと実行ボタンがアクティブになりません。

最初に実行した時に、Googleから認証を求められます。

詳細はこちらhttps://programming-zero.net/gas-first-run-process/ をご覧ください。

ストップウオッチつきタスク管理ツールの概要

スプレッドシートのテンプレート「ToDo」をベースにしています。

こちらhttps://www.acrovision.jp/service/gas/?p=269のページを参考に、プログラムを実行するボタンを設置しました。ボタンに対する関数の割り当て方法も書いてあります。

まず、時間を測定したいタスクの右側の「timer check」にチェックを入れる必要があります。

  • startボタン:ストップウオッチを開始します。測定時間はC1列に表示されます。
  • stopボタン:ストップウオッチを停止します。E列「time」に累計測定時間が表示され、タスク名、スタート時間、ストップ時間でGoogleカレンダーに予定が登録されます。
  • archive&colorボタン:A列のチェックボックスにチェックが入っているタスク(完了したタスク)をTaskLogシートに移動します。また、日付に色をつけます。今日は青色、今日より前は赤色、未来のタスクは黒色に変更します。

startボタンを押すと、下図のようにC1セルに測定時間が表示されます。

残念なことにGASの仕様では、1分間のどこかの時点での更新処理しか実現できないため、表示時間は最大で約2分のずれが生じます。timeに表示される累計時間はstop時点の時刻とstart時点の時刻の差で算出しているので、こちらにずれはありません。

stopボタンを押すとストップウオッチが停止し、Googleカレンダーにチェックを入れたタスク名で予定が作成されます。

ポップアップを消さないと処理が終了しません。

このような感じで予定が登録されます。今回は自分で作成した「勉強」カレンダーに予定を登録する設定にしています。

archive&colorボタンを押すと、完了したタスクがTaskLogに移動します。A列とD列はチェックボックスのTrue or Falseなので、非表示にしています。

時間を計測してカレンダーに登録できるようになりました!

勉強がはかどりそうです。

コード

一回だけcalendarIdをプロパティストアに登録する処理を実行する必要があります。

カレンダーIDの確認方法はこちらhttps://blog-and-destroy.com/41932を参考にしました。

// calendarIdというkeyで変数calendarIdをプロパティストアに登録する
var calendarId = "カレンダーアプリから取得したcalendarID"; 

PropertiesService.getScriptProperties().setProperty("calendarId",calendarId);

実装コードは次のとおりです。

function stopwatch_start() {
  //////////////////////////
  //startボタンに割り当てる関数//
  //ストップウオッチをスタートする//
  /////////////////////////

  var ui = SpreadsheetApp.getUi();

  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("ToDo"); 
  var values = sheet.getRange("D4:D").getValues();
  var hasTrue = false;

  for (var i = 0; i < values.length; i++) {
    if (values[i][0] === true) {
      hasTrue = true;
      break;
    }
  }

  if (hasTrue) {
    // タイマー開始時の時間を取得
    var startTime = new Date().getTime();
    console.log(startTime);

    // グローバル変数として開始時刻を保持
    PropertiesService.getScriptProperties().setProperty("startTime", startTime);
    console.log(PropertiesService.getScriptProperties().getProperty("startTime"));

    // タイマー更新関数を定期的に実行するトリガーを作成
    ScriptApp.newTrigger("_updateElapsedTime")
      .timeBased()
      .everyMinutes(1)
      .create();

    // C1セルを初期化
    sheet.getRange("C1").setValue("0分");
    
  }else{
    ui.alert("timer checkにチェックを入れてください。");
  }
  
}

function stopwatch_stop() {
  //////////////////////////////
  //stopボタンに割り当てる関数///////
  //ストップウオッチをストップする///////
  //////////////////////////////

  var stopTime = new Date().getTime();
  

  // トリガーを削除
  var triggers = ScriptApp.getProjectTriggers();
  for (var i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === "_updateElapsedTime") {
      ScriptApp.deleteTrigger(triggers[i]);
      break;
    }
  }
  
  //タスクの行のE列に累計時間を表示する
  _addMinutesToEColumn(stopTime)

  //予定を作成
  _createCalendarEvents(stopTime)
  PropertiesService.getScriptProperties().deleteProperty("startTime");

  // C1セルをクリア
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  sheet.getRange("C1").clearContent();
}

function _updateElapsedTime() {
  ///////////////////////////////////
  //経過時間を計算してC1セルに表示する関数//
  ///////////////////////////////////

  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("ToDo"); 

  // 開始時刻を取得
  var startTime = PropertiesService.getScriptProperties().getProperty("startTime");
  var startTime_int = parseFloat(startTime).toFixed(0);


  if (startTime) {
    // 現在の時間を取得
    var currentTime = new Date().getTime();

    // 経過時間を計算
    var elapsedTime = currentTime - startTime_int;

    // 分を計算
    var minutes = Math.floor(elapsedTime / 60000);

    // C1セルに時間を表示
    sheet.getRange("C1").setValue("measuring : " + minutes + "");
  }
}

function _createCalendarEvents(stopTime) {
  ////////////////////////////
  //カレンダーにタスクを登録する関数//
  ////////////////////////////

  var ui = SpreadsheetApp.getUi();
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName("ToDo"); 
  var data = sheet.getRange("C4:D").getValues(); // C4からD列のデータを取得

  //カレンダーIDをプロパティストアから取得し、カレンダーオブジェクトを生成
  var calendarId = PropertiesService.getScriptProperties().getProperty("calendarId");
  var calendar = CalendarApp.getCalendarById(calendarId);

  //プロパティストアから取得したstartTimeの処理
  var startTime = PropertiesService.getScriptProperties().getProperty("startTime");
  var startTime_int = parseFloat(startTime).toFixed(0);
  var startTime_number = Number(startTime_int);

  //カレンダーにタイムゾーンを指定
  calendar.setTimeZone("Asia/Tokyo"); 

  for (var i = 0; i < data.length; i++) {
    if (data[i][1] === true) {
      var task = data[i][0]; // C列の値(task)を取得
  
      if(startTime){
        // 予定をカレンダーに登録
        var event = calendar.createEvent(task,new Date(startTime_number), new Date(stopTime));
        ui.alert("予定が作成されました:" + event.getTitle());
      }else{
        ui.alert('stopの前にstartを押してください。')
      }
    }
  } 
}

function archiveAndColor(){
  ////////////////////////////////////
  //archive & colorボタンに割り当てる関数//
  ///////////////////////////////////

  _moveRowsToTaskLog()
  _updateCellColors()
}

function _moveRowsToTaskLog() {
  //////////////////////////////////
  //完了したタスクをTaskLogに移動する関数//
  //////////////////////////////////

  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sourceSheet = spreadsheet.getSheetByName("ToDo"); 
  var targetSheet = spreadsheet.getSheetByName("TaskLog"); 
  
  var sourceData = sourceSheet.getRange("A:E").getValues(); // AからE列までの値を取得
  var targetRange = targetSheet.getRange(targetSheet.getLastRow() + 1, 1, 1, 5);  // 移動先のシートの末尾の次の行に移動するための範囲を取得
  
  for (var i = sourceData.length - 1; i >= 0; i--) {
    if (sourceData[i][0] === true) {
      var rowData = sourceData.splice(i, 1)[0]; // 移動する行のデータを取得して元の配列から削除
      targetRange.setValues([rowData.slice(0, 5)]); // 移動先の範囲に値をセット(AからE列まで)
      targetRange = targetRange.offset(1, 0); // 次の行に移動するために範囲をオフセット
    }
  }
  
  // 移動後のデータを元のシートに反映
  sourceSheet.getRange(1, 1, sourceData.length, 5).setValues(sourceData);
}

function _addMinutesToEColumn(stopTime) {
  //////////////////////////
  //E列に累積時間を表示する関数//
  //////////////////////////

  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName("ToDo"); // シート名を適宜変更する

  var data = sheet.getRange("D:E").getValues(); // D列とE列の値を取得
  var outputData = []; // 出力するデータを格納する配列

  // 開始時刻を取得
  var startTime = PropertiesService.getScriptProperties().getProperty("startTime");
  var startTime_int = parseFloat(startTime).toFixed(0);


  if (startTime) {
    // 経過時間を計算
    var elapsedTime = stopTime - startTime_int;

    // 分を計算
    var minutes = Math.floor(elapsedTime / 60000);
  }

  for (var i = 0; i < data.length; i++) {
    var dValue = data[i][0]; // D列の値
    var eValue = data[i][1]; // E列の値

    if (dValue === true) {
      var newValue = eValue + minutes; // E列の値にminutesを加算

      // E列に値が既に入っている場合は加算した値を使用
      if (eValue !== "") {
        newValue = eValue + minutes;
      }

      // 出力するデータを配列に追加
      outputData.push([newValue]);
    } else {
      // D列がTrueでない場合はE列の値をそのまま出力
      outputData.push([eValue]);
    }
  }

  // 出力データをシートのE列に書き込む
  sheet.getRange(1, 5, outputData.length, 1).setValues(outputData);
}

function _updateCellColors() {
  //////////////////////////////
  //B列の日付の色を変更する関数/////
  /////////////////////////////

  var ui = SpreadsheetApp.getUi();
  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName("ToDo"); 
  var data = sheet.getRange("B4:B").getValues(); // B列の値を取得
  var cell = sheet.getRange("B4:B");
  
  var currentDate = new Date(); // 現在の日時を取得
  // 年、月、日を個別に取得
  var year1 = currentDate.getFullYear();
  var month1 = currentDate.getMonth();
  var day1 = currentDate.getDate();

  
  for (var i = 0; i < data.length; i++) {
    
    var cellValue = data[i][0];
    
    if (cellValue === "") {
      continue; // セルの値が空の場合はスキップ
    }
    
    var cellDate = new Date(cellValue);
    var year2 = cellDate.getFullYear();
    var month2 = cellDate.getMonth();
    var day2 = cellDate.getDate();
    
    if (year1 === year2 && month1 === month2 && day1 === day2) {
      cell.getCell(i+1,1).setFontColor("blue"); // 現在の日付と一致する場合は青色にする
    } else if (year2 < year1 || (year2 === year1 && month2 < month1) || (year2 === year1 && month2 === month1 && day2 < day1))  {
      cell.getCell(i+1,1).setFontColor("red"); // 現在の日付より前の場合は赤色にする
    }else{
      cell.getCell(i+1,1).setFontColor("black"); // それ以外は黒色にする
    }
  }
}

実装のポイントとしては

  • ScriptApp.getProjectTriggers()を使用して測定時間の更新処理を行なった。
    • ここで指定する関数は引数を指定できないので、startTimeをプロパティストアに格納した。
    • プロパティストアから読み込まれるタイムスタンプstartTimeは指数表示(3.11E12みたいな)になっているので、parseFloat(startTime).toFixed(0)で整数表示に変更する。また、Dateオブジェクトにする際にNumber型に変換する必要があるのでNumber()で変換した。
  • スプレッドシートに対する処理は、var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("ToDo")でシート名「ToDo」のシートを取得し、var data = sheet.getRange("C4:D").getValues();のようにgetRange()getValues(),getValue()でセルの値を取得する。
  • ボタンに対する関数の割り当て方法は先ほど紹介したこちらhttps://www.acrovision.jp/service/gas/?p=269のページを参考にした

おわりに

ChatGPTに相談しつつ、半日くらいでアプリをつくることができました。

一からWebアプリケーションを作っていたらもっと大変だったと思います。

Googleのサービスをベースに何か作りたい時にはGAS、便利ですよ〜

コメント

タイトルとURLをコピーしました