にせねこメモ

はてなダイアリーがUTF-8じゃないので移ってきました。

GASを使って作業開始・終了時刻記録Webアプリを作った話

概要

Google Apps Script (GAS)を使って、自分で使うための、作業開始・終了時刻を記録するWebアプリを作成した。

f:id:nixeneko:20210311005657p:plain
作成したアプリ
この記事にあるもの
  • GASによるWebアプリ作成の流れ
  • 作ったアプリのコード
ないもの
  • コードの書き方・解説

モチベーション

  • 作業時間(開始時刻, 終了時刻)を記録したかった
  • 開始時と終了時にボタンを押すだけで記録できるとよい
  • スマホで使えると良い

これを満たすのに、勤怠管理システムが使えるかと思い、無料で使える勤怠管理Webサービスを使っていた。しかし、一日に2回以上の勤怠データを登録することができず、自分の使いたい目的では使えないことが分かった。

そのため、自分で使うためのものを作ることにした。

要件

  • 「出勤」「退勤」ボタンが表示される(実際には「作業開始」「作業終了」だが長いのでこうした)
  • ボタンを押すとその時の時刻およびボタンに対応する「入」「退」のどちらかが記録される
    • 時刻の記録だけに特化する。作業時間の計算等は別にプログラムを書いてどうにかする

GASとは

Google Apps Script (GAS)とは、Googleサービス等と組み合わせて使える、Javascriptをベースとしたスクリプト言語で、Googleドライブとかで実行できる。また、GASを使ってWebサービスを開発したり公開したりできる。

Webサービスであればスマホで使えるのでいいだろうということでGASで作ることにした。

実装方針

  • 勤怠時刻データはそれ用のGoogleスプレッドシートを作成しそこに保存する
  • GASによるWebサービスでは、HTTPリクエストを受けると決められた関数がトリガーされる。GETとPOSTで別の関数となっている:
    • GET (doGet関数)では、ボタンを表示するページを返す
    • POST (doPost関数)では、時刻の記録を行う。記録した後はGETにリダイレクトする(POSTでページを表示した場合、リロードすると再度記録されてしまうため)

実際にやって動かしてみる

まず、Googleドライブを開く*1

記録用スプレッドシートの用意

先に記録用のスプレッドシートを用意する。

「新規」→「Google スプレッドシート」をクリック
f:id:nixeneko:20210310221702p:plain

  • スプレッドシートのタイトルを変更(任意)
  • 見出しを適当に入力。ここでは「時刻」「入退」「備考」にした。
  • シート名を入力。シート名は何でもいいがここでは"time_log"とした。後で使う。
  • スプレッドシートのIDをコピーしておく。後で使う。
    • スプレッドシートのURLがhttps://docs.google.com/spreadsheets/d/XXXXXXXXXXXXXXXXXXXX/editみたいな感じになっているので、XXXXXXXXXXXXXXXXXXXXの部分がIDになる。

Apps Scriptの作成

「新規」をクリック*2→「その他」→「Google Apps Script」をクリック
f:id:nixeneko:20210310215715p:plain

エディタが開くので、「無題のプロジェクト」と書かれている部分をクリックしてプロジェクトの名前を適当に変更する(任意)

実行の流れとしては、コードを変更する→保存する→デプロイする→ページを開いて確認という流れになる。

動作確認

最初にWebアプリを動かしてみる。エディタに次のコードを入力する。

function doGet() {
  const html = '<!DOCTYPE html><html><head><base target="_top"></head><body><h1>Hello World!</h1></body></html>';
  return HtmlService.createHtmlOutput(html);
}
デプロイ

保存ボタン「💾」をクリック、あるいはCtrl+Sを押す→「デプロイ」→「新しいデプロイ」をクリック
f:id:nixeneko:20210310225542p:plain

「種類の選択」の右の歯車「⚙」をクリック→「ウェブアプリ」をクリック
f:id:nixeneko:20210310225656p:plain

「デプロイ」をクリック
f:id:nixeneko:20210310225923p:plain

すると、ウェブアプリのURLが表示されるのでコピーして控えておく。後で使う。
f:id:nixeneko:20210310230247p:plain

ウェブアプリのURLを開くと次のように表示される。
f:id:nixeneko:20210310230440p:plain

コードを変更した際は、まず保存し、「デプロイ」→「新しいデプロイ」→「デプロイ」を繰り返すと変更が反映される。*3

アプリを動かしてみる

さて、こちらに作ったアプリを用意した。これを動かしていく。

「ファイル」の右の「+」をクリック→「HTML」をクリック
f:id:nixeneko:20210310231515p:plain

名前を「index」と入力して確定
f:id:nixeneko:20210310231609p:plain

index.htmlのコードを消して次の内容に書き換える:

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <style type="text/css">
      button {font-size: 15vw; height: 30vw; width: 45vw;}
      input {width: 80vw;}
    </style>
  </head>
  <body>
    <h1>タイムカード</h1>
    <form action="<?= app_url ?>" method="POST" name="f">
      <div>
        <button type="submit" name="action" value="in" <?= in_disabled ?> >出勤</button>
        <button type="submit" name="action" value="out" <?= out_disabled ?> >退勤</button>
      </div>
      <div>備考: <input name="comment"></input></div>
    </form>
    <div>最終: <?= last_time_log ?></div>
  </body>
</html>

コード.gsの内容を次のコードに書き換える。spread_sheet_id, sheet_nameの値は先ほど控えた値に変更する。

const spread_sheet_id = "ここにスプレッドシートのIDを入力"; //記録するスプレッドシートのID
const sheet_name = "time_log" //スプレッドシートのシートの名前

const app_url = ScriptApp.getService().getUrl(); //このアプリのデプロイURL

function getSheet(){
  const spreadsheet = SpreadsheetApp.openById(spread_sheet_id);
  const sheet = spreadsheet.getSheetByName(sheet_name);
  return sheet;
}

function getLastRow(){
  let sheet = getSheet();
  let last_row_idx = sheet.getLastRow();
  let values = sheet.getRange(last_row_idx, 1, 1, 3).getValues()[0];
  return values;
}

function addLastRow(time_str, in_out, comment){
  let sheet = getSheet();
  let last_row_idx = sheet.getLastRow();
  sheet.getRange(last_row_idx+1, 1, 1, 3).setValues([[time_str, in_out, comment]]);
}

function formattedDate(date){
  return Utilities.formatDate(date, "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
}

function doGet(e) {
  let html = HtmlService.createTemplateFromFile('index');
  html.app_url = app_url;
  let date, in_out, comment;
  [date, in_out, comment] = getLastRow();
  let date_str;
  if (date instanceof Date) {
    date_str = formattedDate(date);
  } else {
    date_str = date;
  }
  html.last_time_log = date_str + " " + in_out + " " + comment;
  if (in_out == "入"){ //最後が出勤なら退勤のみ表示
    html.in_disabled = "disabled";
    html.out_disabled = "";
  } else {
    html.in_disabled = "";
    html.out_disabled = "disabled";
  }

  return html.evaluate()
              .addMetaTag('viewport', 'width=device-width, initial-scale=1, user-scalable=no')
              .setTitle('タイムカード');
}

function doPost(e){
  let action = e.parameter.action;
  let now = new Date();
  let date_str = formattedDate(now);
  const action_to_japanese = {"in": "入", "out": "退"};
  let in_out = action_to_japanese[action];
  if (!in_out) throw new Error('POSTパラメータが異常です'); //不正な呼び出し
  let comment = e.parameter.comment;
  addLastRow(date_str, in_out, comment); //書き込み

  // GETにリダイレクトする
  return HtmlService.createHtmlOutput(
    "<script>window.top.location.href='"+ app_url + "';</script>"
  );
}

入力したら保存し、デプロイをする。
(2021-10-11追記: デプロイURLはScriptApp.getService().getUrl();で取得できることがわかったのでそのようにコードを変更した)

認証

デプロイをしようとすると、スプレッドシートにアクセスするために認証が必要になる。

「アクセスを承認」をクリック
f:id:nixeneko:20210310232713p:plain

ウィンドウが開くので今使っているアカウントを選択
f:id:nixeneko:20210310233040p:plain

「このアプリは Google で確認されていません」と表示されるので「詳細」をクリック
f:id:nixeneko:20210310233341p:plain

「time recorder (安全ではないページ) に移動」をクリック(time recorderは設定したプロジェクト名)
f:id:nixeneko:20210310233612p:plain

「許可」をクリック
f:id:nixeneko:20210310233738p:plain

これでデプロイができる。一度やればOK。

ブラウザで開いて確認

次のような画面になる。スマホのサイズでちょうどいい感じ(だがパソコンで開くとすごいバランスになる)。
f:id:nixeneko:20210311005657p:plain

ボタンを押すとスプレッドシートに時刻が書き込まれることが確認できる。
f:id:nixeneko:20210311013206p:plain


これで完成。デザインは適当だが自分しか使わないのでヨシ!
スマホでアプリのURLを開けば実行できる。アプリを作成したのと同じGoogleアカウントでのログインが必要。
ちょっと読み込みが遅い気がする。


ほか工夫点。

  • 入退は交互に来るはずなので、ボタンは交互にグレーアウトするようにした。
  • 作業内容等を書けるようにコメント欄も作った。
  • スプレッドシートの最終行=最後に記録された内容を表示するようにした。

GAS引っかかりメモ

Googleアカウントを切り替えられない?

エディタを開く際、同時に複数のGoogleアカウントにログインしている場合、メインのアカウント以外に切り替えることができないようで、使いたいアカウント以外をログアウトする必要があるっぽい?

デプロイする前に保存するのを忘れて変更が反映されない

よくある。保存してデプロイし直す。

WebアプリのHTML内のJavaScriptから、ブラウザのアドレスバーにあるWebアプリのURLを取得

無理っぽい。iframeに入っていてかつクロスドメインになっているため。なぜ…

タイトルやmetaタグの設定

GASのWebアプリで表示されるページは、二重にiframeが入ったその中にアプリで設定したHTMLが表示される。そのため、HtmlOutputを作成する際のHTMLの中でタイトルやスマホ対応のviewportを指定するmetaタグを設定しても効果がない。

代わりにHtmlOutputオブジェクトの.setTitleメソッド、.addMetaTagメソッドを呼び出して、それぞれタイトルとメタタグを設定する。メソッドチェーンもできる。

return HtmlService.createTemplateFromFile('index').evaluate()
              .addMetaTag('viewport', 'width=device-width, initial-scale=1, user-scalable=no')
              .setTitle('タイムカード');
リダイレクト

window.top.location.hrefにリダイレクト先のURLを代入するJavascriptコードを実行するHTMLを返せばいいっぽい。

  return HtmlService.createHtmlOutput(
    "<script>window.top.location.href='"+ app_url + "';</script>"
  );

参考: google apps script - Automatically Redirecting to a Page - Stack Overflow

(2021-09-03追記: <iframe>sandbox属性にallow-top-navigation-by-user-activationが指定されているためリダイレクトが動かなくなった。これどうしようかな…。)

まとめ

  • 自分だけ使えるWebアプリをタダで作って使えるので便利*4
  • Googleドライブ上のデータを読み書きできる(認証が必要)
  • 自分しか使わないので必要なものを最低限だけ実装すればいい
    • コーナーケースに対するエラー処理もしなくていいかもしれない(運用でカバー)
  • 100行もコードを書かずにできた

*1:使いたいアカウント以外をログアウトしておく必要があるかもしれない。

*2:画像ではマイドライブ▼をクリックしてるけど出るメニューは同じ。ファイル一覧のペインを右クリックしてもよい。

*3:エディタで「デプロイ」→「デプロイをテスト」とすると、最新のコードをテストできるURLが得られるので、開発に有用かもしれない。

*4:設定次第で、他人に使わせたり、一般公開することも可能。