メインコンテンツへスキップ
見出し画像

【GAS + Gemini3 Canvas】Googleスプレッドシートのサイドバーにストップウォッチ機能を実装してみよう

    2025年11月時点では最強と言われている GoogleのAI Gemini3Pro のCanvas機能を使って、Googleスプレッドシートにストップウォッチ機能を実装してみよう!という回です。

    流行りの バイブコーディングってやつですね。

    先週のnoteで取り上げたGoogleドキュメントのストップウォッチチップ、タイマーチップ(GoogleWorkspace限定機能)の流れです。



    サイドバーストップウオッチのゴール

    今回作成するツールのゴール(完成形)です。

    メニューからGoogleスプレッドシートの サイドバーに ストップウォッチを起動。

    STRATボタンでミリ秒単位のストップウォッチが開始。 (こだわりの サイバー厨二デザインw)

    画像

    RECORD ボタンでLAPタイムをサイドバー内に最大10件まで保持。

    STOPボタンでストップウォッチを停止。WRITE LAPS TO SHEET ボタンで選択しているセル(アクティブセル)を起点にミリ秒単位の時間として書き込み。

    画像

    RESET ボタンでストップウォッチをリセット。ストックしていたLAPタイムを消去。初期状態に戻る。

    こんな感じでLapタイムをシームレスにスプレッドシートに記録できる、サイバーパンク風サイドバーストップウォッチです。

    プロンプト、コードは無料公開。

    コピペして自分で作るのが面倒だって人は有料ゾーンですが、コピーしてすぐ使えるGAS入りファイル配布もしています。※スマホでも使えるアプリ版のオマケ付き



    【前提】ストップウォッチをセルに実装することは不可能

    Googleスプレッドシートにストプウォッチ機能を実装と聞いて、セル内に h:mm:ss.000 のストップウオッチを表示させてGASで動かすイメージを持つ人がいますが、残念ながらセル内ストップウォッチは現実的とはいえません。

    画像

    👆GASだけで無理やり作ったセル内ストップウォッチ

    一応、無理やりですが GASでひたすら開始時と new Date() をミリ秒にした差分をシリアル値化した 値を setValue() して SpreadsheetApp.flush() で画面更新をひたすら繰り返すことで、それっぽい動きは出来なくはないです。

    ただsetValue() に時間がかかるんで、ミリ秒の動きはスムーズとは言えませんし、STARTとSTOPを押してからのタイムラグがどうしてもあります。

    それ以外にも GASの実行制限 6分の壁 や高負荷という懸念もありますし、これはおススメしません。



    Excel VBA利用ならセル内ストップウォッチは作成可能

    画像

    Excel VBAなら短いコードで セル内ストップウォッチ機能は実現できます。

    こちらはエクセルの神髄さんがコピペで利用できるコードを紹介しています。




    GoogleWorkspace(Businesse Standard以上)なら、Googleドキュメントにストップウォッチ チップ機能がある

    実はGoogleドキュメントのスマートチップには、ストップウォッチ チップやタイマーチップがあります。

    画像

    このストップウォッチチップやタイマーチップがスプレッドシートでも使えれば良いんですが、残念ながら ストップウォッチチップ、タイマーチップは Googleドキュメント限定の機能です。

    ドキュメントやローカルのExcelと違って、スプレッドシートの場合は、セルにストップウォッチ機能を持たせた時のサーバーサイドとの同期の問題や、他のセルから参照した時の再計算のとんでもない負荷を考えると、セルへの実装は難しいだろうなと感じます。

    というわけで、スプレッドシートにストップウォッチ機能をつける現実的な実装方法としては、

    ストップウォッチ自体は サイドバーでWebアプリとして JavaScriptとHTMLで作成して、そこで測定・記録したラップタイムをスプレッドシート側にGASで書き込む

    といった処理になります。

    これは 最強AI Gemini3も同様の回答をしています。

    画像

    サーバー側ではなく、クライアント側でストップウォッチは動かした方がいいってことです!



    Geminiでバイブコーディングしよう

    ストップウォッチの実装は、JavaScriptの再帰処理の基本教材みたいなもんですし、HTML側は DOMや見栄えを整える為の CSSの理解、さらにWeb(サイドバー側)からGAS実行する為の google.script.run と、色々学びが多い題材なんですが、これらの理解が不足していても今はさくっとAIで作れちゃうんですよね。

    嬉しいような悲しいようなw

    実際にAI(Gemini)を使ってスプレッドシートサイドバーストップウォッチを実装する流れを見ていきましょう。



    Googleスプレッドシート用 サイドバーストップウォッチのコードを作成する為のプロンプト

    Googleスプレッドシートのサイドバーにミリ秒単位で計測できるストップウォッチを実装するコードを作成して。
    
    #機能
    ・スタート、ストップ、記録、リセット、書き込み の5つのボタンを作成。
    ・記録ボタンは、ストップウォッチを止めずに記録ボタンを押した時点のラップタイムを サイドバーにストックする
    ・記録ボタンでストックできるラップタイムは最大10件とする
    ・リセットを押すとストップウォッチは0に戻り、ストックしたラップタイムもクリアされる
    ・書き込みボタンを押すとストックしたラップタイムを アクティブなセルに書き出す(文字列ではなく経過時間とする)
    
    #デザイン
    ・サイバーチックなクールなデザインとする
    ・配置は上からストップウォッチ、その下に2列でスタート、ストップ、記録、リセットのボタン。
     その下に書き込みのボタンを横長で。最下部にLAPタイムの記録エリアとする
    
    #留意点
    ・サイドバー内に表示するので横幅を考慮してください
    ・ストップウォッチの数字表示がブレないように 数字部分は等幅フォントを使用
    ・ストップウォッチの表示領域の高さは固定としてください
    

    👆Googleスプレッドシートのサイドバーにストップウォッチを実装するコードをGeminiに書いてもらう為のプロンプトです。

    あくまでも一例なのでお好みで調整してください。デザインの指示はあまり詳しくないのでざっくりとした指示となっていますw

    手順としてはまず

    Geminiを開いて

    画像

    プロンプトをコピペ、 CanvasをONにして、モデルは 思考モード(3Pro)を選び実行。

    とりあえず、これだけです。

    ※あとで気づきましたが、これ正しくはLAPタイムじゃなくてSPLITですね。。まぁスプレッドシート上で数式で算出できるんでよしとしましょうw



    Gemini Canvas上で確認と修正指示をする

    実行すると、こんな感じでGAS用とUI用の2つコードファイルを生成してくれるので、

    画像
    ※こちらは2.5Proの時のものです

    UI(html)の方のコードを開いて

    画像

    プレビューで見た目と動きを確認してみましょう。

    スプレッドシートへの書き込み処理以外は、ここで動作確認が可能です。



    プロンプトのポイント

    ストップウォッチ作成のプロンプトのポイントは

    ストップウォッチの数字表示がブレないように 数字部分は等幅フォントを使用

    この部分でしょうか。この指示を入れないと

    👇こんな感じで

    画像

    デザインはいい感じなんですが、肝心のストップウォッチが動いている時の表示のブレ(がたつき)がひどいことになります。

    これは 幅サイズが不ぞろいの数字フォントを使用したことで発生するストップウォッチあるあるです。

    もし指示してもコード作成時の考慮から漏れてブレブレが発生してしまった場合は、

    画像

    このように修正をリクエストしましょう。

    等幅フォントに変更されると、数字のブレは解消されます。

    画像

    とりあえずプレビュー上の見た目、動作は大丈夫そうですね!

    ※mirのストップウォッチのコードは最後の方にあります



    スプレッドシートのエディタにコピペして保存する

    これをスプレッドシートのスクリプトエディタにコピペして、実際にサイドバーでの表示と動きを確認しましょう。

    画像

    新しいスプレッドシートを立ち上げて、拡張機能 > App Script でスクリプトエディタを立ち上げ、先に 元々入ってる 👇は削除しておきます。

    function myFunction() {
      
    }


    Gemini側からのコードのコピーですが、複数のコードファイルが生成された場合は、チャットから探すのではなく右上の「このチャット内のファイル」から開くと簡単です。

    画像

    まずは GAS側のファイルを開き、右上の共有アイコンから「内容をコピー」を選択して

    画像

    エディタの コード.gs に貼り付け

    画像

    もう1つのHTMLやJavaScript、CSSが書かれたコードは、エディタで別ファイルを作って貼り付けます。

    Geminiに聞けば指示してくれますが、作るファイルはHTMLで、

    画像

    ファイル名は先ほど貼ったコードの

    画像

    この部分に合せます。

    画像
    Sidebar だけ入れれば、.htmlは勝手に付きます

    htmlファイルも初期コードが入ってるので消しておきましょう。

    Gemini側の UI用コードを開いてコピー、エディタに貼り付けます。

    画像

    エディタの左上「無題のプロジェクト」という文字をクリックすると、プロジェクト名の変更が出来るので、適当にプロジェクトの名前を付けて

    画像

    フロッピーアイコンをクリックでコードを保存

    画像

    これでコードが保存されました。

    プロジェクト名は日本語でもOKです。



    GASの初回承認をする

    コード.gsに切り替えて onOpen を手動で実行してみましょう。

    画像

    初回はGASの承認が必要です。

    画像

    自分のアカウントを選ぶ

    画像

    ここで先ほど名前を変更したプロジェクト名が表示されますが、タイムラグがあるようで「無題のプロジェクト」のままになっていることがあります。

    気にせずでOK。


    お馴染みの初見殺しの画面。ここは左下の「詳細」を開いて

    画像

    ようやく表示される「安全でないページに移動」をクリック(安全なので気にせずOK)

    画像

    すべて選択にチェックしてから「続行」を選択。

    画像

    この時GoogleアカウントでログインしているスマホやGmailにセキュリティ通知が飛んできます。これも放置でOK

    画像
    メールも届く

    スプレッドシートのメニューの一番右に「ストップウォッチ」が
    追加されていれば初回設定は終了。

    画像

    ファイルを共有して複数人で使う場合は、ユーザー毎に初回の承認が必要となります。



    ストップウォッチの表示と動作をテストする

    画像

    ストップウォッチメニューから「サイドバーを開く」をクリックすると、サイドバーのストップウォッチが開きます。

    STARTボタンで動かしてみましょう。

    画像

    数字の表示、RECORDボタンを押した時のLapタイムの記録も大丈夫ですね。

    ボタンの非活性化やマウスオーバーで光るなど、細かい見栄えは具体的な指示が無くてもGeminiがいい感じに仕上げてくれてます。

    この辺りの自分でやると面倒な見栄え部分の styleのコーディングをサクッと作ってくれるのがAIを使ったバイブコーディングのいいところです。

    画像

    Lapタイムとして記録出来るのはとりあえず10件までとしているので、10件記録すると RECORDボタンは押せなくなります。

    記録したLAPタイムは、「WRITE LAPS TO SHEET」ボタンでスプレッドシートの選択しているセル(activeCell)を起点に書き込むことが出来ます。

    セルの表示形式は初期値の「自動だ」とミリ秒単位は表示されないんですが、ちゃんとにGASのコードに .setNumberFormat('[hh]:mm:ss.000') が入ってるので、手動で表示形式を変えなくてもミリ秒単位の経過時間がセルに表示されます。

    画像

    動作も問題ないですね。

    ストップウォッチサイドバーがサクッと完成しました~。



    サイドバーストップウォッチのコードと注意点

    何度かイチからサイドバーストップウォッチを作成して、プロンプトを調整したので、上記手順で作成した場合は手直しがあまりいらないと思います。

    ただ、Geminiは毎回同じコードを回答するわけではありません。



    デザインはガチャ。ハズレもある

    運とタイミングによっては、全然違う見た目のものが出来上がったり、修正指示が何度も必要になることもあります。

    同じプロンプトで何度か試した結果 👇

    画像
    色合いのバランスがイマイチ
    画像
    ボタンの配置やフォントがイマイチ

    ぶっちゃけ調子いい時と悪い時の差がありますw

    というわけで、mirが生成したサイバー厨二デザインのストップウォッチが使いたい!という人の為に、今回のコードを掲載しておきます。



    サイドバーストップウォッチのコード (コード.gs)

    /**
     * スプレッドシートを開いた時(またはリロードした時)に実行されます。
     * [ストップウォッチ] メニューをUIに追加します。
     */
    function onOpen() {
      SpreadsheetApp.getUi()
        .createMenu('ストップウォッチ')
        .addItem('サイドバーを開く', 'showSidebar')
        .addToUi();
    }
    
    /**
     * メニューから呼び出され、サイドバーを表示します。
     */
    function showSidebar() {
      // Sidebar.html という名前のHTMLファイルを読み込みます
      const html = HtmlService.createHtmlOutputFromFile('Sidebar')
        .setTitle('Cyber Stopwatch');
      SpreadsheetApp.getUi().showSidebar(html);
    }
    
    /**
     * HTMLサイドバーから呼び出されます(google.script.run経由)。
     * 記録されたラップタイム(ミリ秒の配列)を受け取り、
     * アクティブセルを起点に経過時間として書き込みます。
     *
     * @param {number[]} lapsInMs - ミリ秒単位のラップタイムの配列。
     */
    function writeLapsToSheet(lapsInMs) {
      const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
      const cell = sheet.getActiveCell();
      
      // 1日は 24 * 60 * 60 * 1000 ミリ秒
      const MS_PER_DAY = 86400000;
    
      if (!lapsInMs) return;
    
      // ミリ秒をGoogleスプレッドシートのシリアル値(日数)の二次元配列に変換
      let serialValues = lapsInMs.map(ms => [ms / MS_PER_DAY]);
          
      // 対応するセル範囲を取得、書き込みし、数値形式を「経過時間(ミリ秒表示付き)」に指定
      cell.offset(0,0,serialValues.length)
               .setValues(serialValues)
               .setNumberFormat('[hh]:mm:ss.000'); // [hh] は24時間を超えてもOKな形式
    }

    スプレッドシートの書き込み処理部分が Geminiの書いたコードだと

    画像
    元のGeminiのコード

    こんな感じの forEachで1セルずつ書き込む処理だったんで、ここだけ気に入らないので setValuesを使った一括処理に修正しました。

    offsetを使うテクニックは前にQuickTipsで紹介しています。



    サイドバーストップウォッチのコード (Sidebar.html)

    <!DOCTYPE html>
    <html>
    <head>
      <base target="_top">
      <!-- Google Fonts (Orbitron: デジタル風, Share Tech Mono: 等幅) -->
      <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
      
      <style>
        /* --- Cyberpunk Design --- */
        :root {
          --bg-dark: #0a0a1a;        /* 濃い青紫 (背景) */
          --bg-mid: #1a1a3a;          /* やや明るい背景 */
          --primary-neon: #00ffcc;    /* ブライトシアン (メインカラー) */
          --secondary-neon: #ff0099; /* ショッキングピンク (アクセント) */
          --accent-green: #00ff00;   /* スタートボタン用 */
          --text-light: #f0f0f0;
          --text-dim: #888;
        }
    
        body {
          font-family: 'Share Tech Mono', monospace;
          background-color: var(--bg-dark);
          color: var(--text-light);
          margin: 0;
          padding: 16px;
          overflow-x: hidden; /* サイドバーの横幅に合わせる */
        }
    
        .container {
          display: flex;
          flex-direction: column;
          /* サイドバーの高さからパディング分を引く */
          height: calc(100vh - 32px); 
        }
    
        /* タイマー表示エリア */
        .timer-display {
          /* font-family: 'Orbitron', sans-serif; */ /* <-- 変更前 */
          font-family: 'Share Tech Mono', monospace; /* <-- 変更後: 等幅フォントに変更 */
          font-size: 2.1rem; /* サイドバーの幅に収まるように調整 */
          font-weight: 700;
          color: var(--primary-neon);
          background-color: var(--bg-mid);
          padding: 10px;
          text-align: center;
          border-radius: 8px;
          border: 2px solid var(--primary-neon);
          box-shadow: 0 0 15px var(--primary-neon), inset 0 0 10px rgba(0, 255, 204, 0.5);
          text-shadow: 0 0 8px var(--primary-neon);
          
          /* --- ブレ防止ここから --- */
          /* 1. 数字を等幅フォントとして扱う (Share Tech Mono自体が等幅だが念のため) */
          font-variant-numeric: tabular-nums;
          /* 2. 高さを固定 */
          height: 50px; 
          line-height: 50px; /* 垂直中央揃え */
          /* --- ブレ防止ここまで --- */
          
          letter-spacing: 1px;
        }
    
        /* ボタンコンテナ */
        .controls {
          display: grid;
          /* 2列レイアウト */
          grid-template-columns: 1fr 1fr;
          gap: 10px;
          margin-top: 20px;
        }
    
        /* 共通ボタン スタイル */
        .controls button, .action-button {
          font-family: 'Share Tech Mono', monospace;
          font-size: 1rem;
          padding: 12px 5px; /* 幅が狭いので左右パディングを減らす */
          border: 2px solid var(--primary-neon);
          background-color: transparent;
          color: var(--primary-neon);
          border-radius: 6px;
          cursor: pointer;
          transition: all 0.3s ease;
          text-transform: uppercase;
          overflow: hidden; /* テキストがはみ出たら隠す */
          text-overflow: ellipsis;
          white-space: nowrap;
        }
    
        .controls button:hover:not(:disabled), 
        .action-button:hover:not(:disabled) {
          background-color: var(--primary-neon);
          color: var(--bg-dark);
          box-shadow: 0 0 10px var(--primary-neon);
        }
    
        .controls button:disabled, 
        .action-button:disabled {
          border-color: #555;
          color: #555;
          cursor: not-allowed;
          background-color: transparent;
        }
    
        /* スタートボタン */
        #start:not(:disabled) {
          border-color: var(--accent-green);
          color: var(--accent-green);
        }
        #start:hover:not(:disabled) {
          background-color: var(--accent-green);
          box-shadow: 0 0 10px var(--accent-green);
          color: var(--bg-dark);
        }
    
        /* ストップボタン */
        #stop:not(:disabled) {
          border-color: var(--secondary-neon);
          color: var(--secondary-neon);
        }
        #stop:hover:not(:disabled) {
          background-color: var(--secondary-neon);
          box-shadow: 0 0 10px var(--secondary-neon);
          color: var(--bg-dark);
        }
    
        /* 書き込みボタン (1列) */
        .action-button {
          margin-top: 10px;
          width: 100%;
        }
    
        /* ラップリスト */
        .laps-container {
          margin-top: 20px;
          flex-grow: 1; /* 残りの高さをすべて使う */
          background-color: var(--bg-mid);
          border: 1px solid var(--primary-neon);
          border-radius: 8px;
          padding: 10px;
          box-shadow: inset 0 0 8px rgba(0, 255, 204, 0.5);
          
          /* スクロール */
          overflow-y: auto;
          min-height: 100px; /* 最小高さを確保 */
        }
    
        .laps-container h3 {
          margin: 0 0 10px 0;
          color: var(--primary-neon);
          text-align: center;
          border-bottom: 1px dashed var(--primary-neon);
          padding-bottom: 5px;
          font-size: 1rem;
        }
    
        #laps-list {
          list-style: none;
          padding: 0;
          margin: 0;
        }
    
        .lap-item {
          display: flex;
          justify-content: space-between;
          padding: 6px 4px;
          border-bottom: 1px solid #444;
          font-size: 0.95rem;
        }
    
        .lap-item:last-child { border-bottom: none; }
    
        .lap-item.empty {
          color: var(--text-dim);
          justify-content: center;
        }
    
        .lap-number { color: var(--text-dim); }
        .lap-time {
          color: var(--text-light);
          /* ラップタイムもガタツキ防止 */
          font-variant-numeric: tabular-nums; 
        }
    
        /* --- カスタムスクロールバー --- */
        .laps-container::-webkit-scrollbar { width: 8px; }
        .laps-container::-webkit-scrollbar-track {
          background: var(--bg-dark);
          border-radius: 4px;
        }
        .laps-container::-webkit-scrollbar-thumb {
          background: var(--primary-neon);
          border-radius: 4px;
        }
        .laps-container::-webkit-scrollbar-thumb:hover {
          background: #fff;
        }
      </style>
    </head>
    <body>
      
      <div class="container">
        <!-- タイマー表示 -->
        <div id="timer" class="timer-display">00:00:00.000</div>
    
        <!-- 操作ボタン -->
        <div class="controls">
          <button id="start">Start</button>
          <button id="stop">Stop</button>
          <button id="record">Record</button>
          <button id="reset">Reset</button>
        </div>
    
        <!-- 書き込みボタン -->
        <button id="write" class="action-button">Write Laps to Sheet</button>
    
        <!-- ラップタイムリスト -->
        <div class="laps-container">
          <h3>Lap Times (Max 10)</h3>
          <ul id="laps-list">
            <!-- JSによって動的に生成される -->
          </ul>
        </div>
      </div>
    
      <script>
        // --- 要素の取得 ---
        const timerDisplay = document.getElementById('timer');
        const startBtn = document.getElementById('start');
        const stopBtn = document.getElementById('stop');
        const recordBtn = document.getElementById('record');
        const resetBtn = document.getElementById('reset');
        const writeBtn = document.getElementById('write');
        const lapsList = document.getElementById('laps-list');
    
        // --- 状態変数 ---
        let startTime = 0;       // タイマー開始時刻 (performance.now())
        let elapsedTime = 0;     // 経過時間 (ミリ秒)
        let laps = [];           // ラップタイム (ミリ秒) の配列
        let running = false;     // タイマー動作中フラグ
        let animationFrameId = null; // アニメーションフレームID
    
        // --- 関数 ---
    
        /**
         * ミリ秒を HH:MM:SS.SSS 形式の文字列にフォーマットする
         */
        function formatTime(ms) {
          const totalSeconds = Math.floor(ms / 1000);
          const hours = Math.floor(totalSeconds / 3600);
          const minutes = Math.floor((totalSeconds % 3600) / 60);
          const seconds = totalSeconds % 60;
          const milliseconds = Math.floor(ms % 1000);
    
          return (
            String(hours).padStart(2, '0') + ':' +
            String(minutes).padStart(2, '0') + ':' +
            String(seconds).padStart(2, '0') + '.' +
            String(milliseconds).padStart(3, '0')
          );
        }
    
        /**
         * タイマー表示を更新する
         */
        function updateTimerUI(time) {
          timerDisplay.textContent = formatTime(time);
        }
    
        /**
         * ラップリストのUIを更新する
         */
        function updateLapsUI() {
          lapsList.innerHTML = ''; // リストをクリア
          
          if (laps.length === 0) {
            lapsList.innerHTML = '<li class="lap-item empty">No laps recorded.</li>';
            return;
          }
    
          // (最大10件だが、配列全体をレンダリング)
          laps.forEach((lapMs, index) => {
            const li = document.createElement('li');
            li.className = 'lap-item';
            
            const lapNumber = document.createElement('span');
            lapNumber.className = 'lap-number';
            lapNumber.textContent = `Lap ${index + 1}`;
            
            const lapTime = document.createElement('span');
            lapTime.className = 'lap-time';
            lapTime.textContent = formatTime(lapMs);
            
            li.appendChild(lapNumber);
            li.appendChild(lapTime);
            lapsList.appendChild(li);
          });
        }
    
        /**
         * タイマーの描画ループ (requestAnimationFrame でスムーズに)
         */
        function tick() {
          if (!running) return; // ストップしたらループ停止
          
          // (startTime は start ボタン押下時に performance.now() - elapsedTime で設定済み)
          elapsedTime = performance.now() - startTime;
          updateTimerUI(elapsedTime);
          
          // 次のフレームを要求
          animationFrameId = requestAnimationFrame(tick);
        }
    
        // --- イベントリスナー ---
    
        // スタート
        startBtn.addEventListener('click', () => {
          if (running) return;
          running = true;
          
          // 停止していた時間(elapsedTime)を引いて、再開時の基準時刻を計算
          startTime = performance.now() - elapsedTime; 
          
          startBtn.disabled = true;
          stopBtn.disabled = false;
          recordBtn.disabled = false; // 記録ボタンを有効化
          
          // ラップが10件未満の場合のみ記録ボタンを有効化
          if (laps.length < 10) {
            recordBtn.disabled = false;
          }
          
          tick(); // ループ開始
        });
    
        // ストップ
        stopBtn.addEventListener('click', () => {
          if (!running) return;
          running = false;
          cancelAnimationFrame(animationFrameId);
          animationFrameId = null;
          
          // 最終的な経過時間を確定
          elapsedTime = performance.now() - startTime; 
          updateTimerUI(elapsedTime); // 念のため最終描画
          
          startBtn.disabled = false;
          stopBtn.disabled = true;
          recordBtn.disabled = true; // 停止中は記録不可
        });
    
        // 記録 (ラップ)
        recordBtn.addEventListener('click', () => {
          // 動作中のみ記録
          if (!running) return;
          
          if (laps.length < 10) {
            // 現在の経過時間をスナップショット
            laps.push(performance.now() - startTime); 
            updateLapsUI();
          }
          
          // 10件に達したら(今回で10件目になったら)記録ボタンを無効化
          if (laps.length >= 10) {
            recordBtn.disabled = true; 
            console.log("Lap limit reached (10).");
          }
        });
    
        // リセット
        resetBtn.addEventListener('click', () => {
          running = false;
          if (animationFrameId) {
            cancelAnimationFrame(animationFrameId);
            animationFrameId = null;
          }
          
          elapsedTime = 0;
          startTime = 0; 
          laps = [];
          
          updateTimerUI(0);
          updateLapsUI();
          
          // ボタンの状態を初期化
          startBtn.disabled = false;
          stopBtn.disabled = true;
          recordBtn.disabled = true; // リセット時も記録不可
        });
    
        // 書き込み
        writeBtn.addEventListener('click', () => {
          if (laps.length === 0) {
            console.log("No laps to write.");
            // (UIでのフィードバック推奨)
            return;
          }
          
          writeBtn.disabled = true;
          writeBtn.textContent = "Writing...";
          
          google.script.run
            .withSuccessHandler(() => {
              console.log("Laps written successfully.");
              writeBtn.disabled = false;
              writeBtn.textContent = "Write Laps to Sheet";
            })
            .withFailureHandler((err) => {
              console.error("Failed to write laps:", err);
              writeBtn.disabled = false;
              writeBtn.textContent = "Write Failed (Retry)";
            })
            .writeLapsToSheet(laps);
        });
    
        // --- 初期化 ---
        document.addEventListener('DOMContentLoaded', () => {
          // 初期状態設定
          stopBtn.disabled = true;
          recordBtn.disabled = true;
          
          // 初期UI描画
          updateTimerUI(0);
          updateLapsUI();
        });
    
      </script>
    </body>
    </html>
    

    ボタンの文字を手動で日本語にしたい場合は

    画像

    ここをいじってください。

    画像

    自分でわからない人や、その他の細かい変更もGeminiに依頼しちゃってOKです。



    無料版でも使える量増えた? Gemini3Pro

    画像

    これまで無課金アカウントで Gemini2.5Pro を利用してバイブコーディングをしていると、何度かコード修正するだけであっという間に上限になって使えなくなっちゃいました。

    しかし、Gemini3Proになったことで 上限がアップした印象。(体感ベース)

    今回くらいのコードであれば5,6回までは出力することが出来ます。

    ぶっちゃけこの程度のコードであれば Gemini2.5Proでも十分なんですが、上限の緩和や手直しのやり取りが減った ことは Gemini3Proになったメリットと言えるでしょう。

    画像

    もちろんGemini3Proも使いすぎると上限に達して使えなくなります。無料アカウントの場合は無駄使いに注意しましょう。



    サイドバーストップウォッチは スマホアプリ版に非対応

    スマホアプリ版のスプレッドシートは、サイドバーやカスタムメニューが使えません。

    その為、今回実装したサイドバースプレッドシートは基本的にはスマホ非対応となります。



    【おまけ】スプレッドシート書き出し機能付きストップウォッチWebアプリ

    今回のストップウォッチをスマホでも使いたい場合は、サイドバーで表示するのではなくストップウォッチを doGet() でWebアプリ化する必要があります。


    GAS Webアプリ版ストップウォッチへ変更するプロンプト

    ただし、この場合アクティブなセル(選択セル)がWebアプリ側から取得出来ないので、書き込み先のセルの指定方法を変える必要があります。

    サイドバーにストップウォッチを実装してアクティブセルにラップタイムを書き込む以下のコードを修正してください
    
    #独立したWebアプリとする
    サイドバーではなくdoGet() を使ったWebアプリに変更する
    
    #別のスプレッドシートに書き込みできるWebアプリとする
    スクリプトをコンテナバインドしているスプレッドシートの 設定 という名前のシートのA2セルにある スプレッドシートのURLを参照し、そのスプレッドシートにラップタイムを書き込めるようにする
    
    #書き込み先のシート、列はプルダウン選択とする
    書き込み先のスプレッドシートのシート情報を取得して、アプリ内(UI表示)でプルダウンで書き込み先のシートを選択。さらに書き込み先の列は 選択したシートの列数を取得してA列,B列,C列といった形で同じくプルダウンで指定できるようにする
    
    #書き込み先のセル
    書き込むセルは、プルダウンで指定したシートの指定列の値が入っている一番下のセルの1つ下を起点とする。
    
    #書き込み完了の表示
    書き込みが正しく完了したことを 画面に英語で表示するようにしてください
    
    #UIデザイン
    UIの基本的なデザインはそのままで、プルダウンはストップウォッチとボタンの間に表示する
    スマホで開いた時に自動で最適なサイズになるようにする
    

    👆というわけで、こんな感じの修正プロンプトでGeminiにコードを修正してもらいました。

    上記プロンプトと元のコードを渡したら、Gemini3Proが一発で問題なく使えるコードを返してきたのは凄いです!

    これで事前に設定シートでURLで指定したスプレッドシートに対して、スマホで開いた ストップウォッチアプリからシート、列を選択してその一番下の行にラップタイムを書き込むことができました。

    画像

    スタンドアロン型でもいいんですが、書き込み先のスプレッドシートを切り替える際にスプレッドシート上で指定した方がユーザーが使いやすいかなと判断してコンテナバインド型としています。デプロイの更新も不要ですしね!



    Webアプリ版 スプレッドシート連携 ストップウォッチのメリット

    Webアプリ版のメリットとしては

    ✅URLでスプレッドシートを指定するだけで、どのスプレッドシートに対してもLAPタイムを記録できる
    ✅スマホから利用できる
    ✅GASの初回承認は、デプロイする人がやればOK。ユーザーはGASの承認なしで利用できる。
    ✅ユーザーは閲覧のみや権限のないスプレッドシートにもLAPタイムを記録できる

    ※LAPタイムの書き込み先は Webアプリの実行ユーザー(デプロイした人)が編集権限のあるスプレッドシートに限定さます

    とりあえず スマホで使えて、スプレッドシートに書き込みできるがいい感じです!

    ただし、GASのWebアプリはAndroidのChromeに複数アカウントが紐づいている場合

    画像

    このように「ファイルを開くことができません。」となってしまうバグがあります。

    その場合はシークレットモードで開くか、別のブラウザアプリで開く等の対応を。

    画像
    ミスマッチ感がすごいw

    シークレットだとスクショが取れないので、mirはリトルミイスキン仕様のYahooアプリで動かしました



    Gemini3でバイブコーディングデビューを!

    Gemini3Proの登場で、よりスムーズにバイブコーディングが出来るようになたんじゃないでしょうか?

    ただ、Gemini2.5Proの時に見られたやや頑固な性格やは変わらず発生しますし、たまに変な方向に突っ走ることがあるんで注意!

    今回はストップウォッチという定番ネタでしたが、これ出来ないかな?と思ったことをまずはGeminiに相談してみましょう!デザインに自信がない人でも、AIがいい感じの見た目に仕上げてくれますよ~。

    次回はQUERY関数シリーズの続きを再開します!



    【有料コンテンツ】すぐ使えるコード入りスプレッドシートを提供します

    最後に コードのコピペすら 面倒だからコピーしてすぐ使えるシートが欲しい!って人向けに、 有料コンテンツとして今回の サイドバーストップウォッチ機能入り Googleスプレッドシートと 指定したスプレッドシートに書き込みできるWebアプリ版ストップウォッチを配布します。

    解説は全て無料 部分に記載しているので、有料コンテンツに さらなる情報はありません。

    • 面倒だから 出来上がったスプレッドシートを使いたいって人

    • 単に mir を応援したいんで 課金しますという足ながおじさんな人

    • 無料部分の記事が参考になった、面白かったんで 支援しますって人

    こんな方だけ 有料コンテンツをご利用ください。

    多少なりとも 有料コンテンツ利用者がいると、モチベーションにもつながりますし、執筆にも力が入りますので感謝感謝です。

    よろしければ 是非!

    ↓以下、有料コンテンツです。



    【有料】サイドバーストップウォッチ、スプレッドシートに書き込みできるストップウォッチアプリ

     
     

    mir

     
     
    元Excel職人・VBA使いから、Googleスプレッドシート職人・GAS使いにジョブチェンジ。謎解き感覚で お題(課題)を解決していくような記事を書こうかなと。その他、AIやらGeminiやらGoogleWorkspaceネタ全般

    あなたへのおすすめ