[Vibe Coding #1] コードゼロで紙吹雪アプリを自作する方法【スプレッドシート激変 / GAS×AI活用】のおまけ

コード.gs

/**
* スプレッドシートが開かれたときに実行される関数
*/
function onOpen() {
 SpreadsheetApp.getUi()
     .createMenu('🎉 タスク完了アプリ')
     .addItem('サイドバーを開く', 'showSidebar')
     .addToUi();
}


/**
* サイドバーを表示する関数
*/
function showSidebar() {
 var html = HtmlService.createHtmlOutputFromFile('index')
     .setTitle('Task Complete! 🎉')
     .setWidth(300);
  SpreadsheetApp.getUi().showSidebar(html);
}


/**
* シートからタスク一覧を取得する関数
* A列:タスク名、B列:ステータス("完了"が入ると完了扱い)と想定
*/
function getTasks() {
 var sheet = SpreadsheetApp.getActiveSheet();
 var lastRow = sheet.getLastRow();
  // データが少なすぎる場合は空配列を返す
 if (lastRow < 2) return [];
  // A列(タスク名)とB列(ステータス)をまとめて取得
 // 2行目から最終行まで(1行目はヘッダーと仮定)
 var values = sheet.getRange(2, 1, lastRow - 1, 2).getValues();
  var tasks = [];
 for (var i = 0; i < values.length; i++) {
   // タスク名が空でない場合のみリストに追加
   if (values[i][0] !== "") {
     tasks.push({
       rowIndex: i + 2, // 行番号 (配列index + 2)
       name: values[i][0],
       isCompleted: values[i][1] === "完了" // B列に"完了"とあれば済み扱い
     });
   }
 }
 return tasks;
}


/**
* 指定した行のタスクを完了にする関数
*/
function markTaskAsComplete(rowIndex) {
 try {
   var sheet = SpreadsheetApp.getActiveSheet();
   // 指定行のA列(タスク名)とB列(ステータス)の範囲を取得
   var range = sheet.getRange(rowIndex, 1, 1, 2);
  
   // 1. 背景色を「黄色」に変更
   range.setBackground('#fff3cd');
  
   // 2. A列のテキストに取り消し線を引く
   range.getCell(1, 1).setFontLine('line-through');
   range.getCell(1, 1).setFontColor('#6c757d');
  
   // 3. B列に「完了」という文字を入れる
   range.getCell(1, 2).setValue('完了');
  
   return true;
 } catch (e) {
   Logger.log(e);
   return false;
 }
}

index.html

<!DOCTYPE html>
<html>
 <head>
   <base target="_top">
   <!-- Bootstrap 5 CSS -->
   <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
   <!-- Animate.css -->
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
   <!-- Bootstrap Icons -->
   <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
  
   <style>
     body {
       background-color: #f8f9fa;
       font-family: 'Helvetica Neue', Arial, sans-serif;
       padding: 15px;
       text-align: center;
       overflow-x: hidden;
     }


     /* 称賛メッセージエリア */
     #praiseMessage {
       font-size: 1.8rem;
       font-weight: 800;
       color: #FF512F;
       min-height: 2.5rem;
       margin-bottom: 15px;
       text-shadow: 1px 1px 0px rgba(0,0,0,0.1);
     }


     /* タスクリストのコンテナ */
     #taskList {
       text-align: left;
       margin-bottom: 30px;
       max-height: 400px;
       overflow-y: auto;
     }


     /* タスクアイテムのデザイン */
     .task-item {
       transition: all 0.3s;
       border-left: 5px solid #0d6efd; /* 未完了は青 */
     }
    
     .task-item.completed {
       background-color: #e9ecef;
       border-left: 5px solid #6c757d; /* 完了はグレー */
       text-decoration: line-through;
       color: #6c757d;
       pointer-events: none; /* 完了済みはクリック不可に */
     }


     /* 完了ボタン(リスト内の小さなボタン) */
     .btn-check-task {
       border-radius: 50%;
       width: 32px;
       height: 32px;
       padding: 0;
       display: flex;
       align-items: center;
       justify-content: center;
       font-size: 14px;
     }


     /* フッター */
     .footer-greeting {
       color: #6c757d;
       font-size: 0.8rem;
       margin-top: 20px;
       border-top: 1px solid #dee2e6;
       padding-top: 10px;
     }
    
     /* ローディング表示 */
     #loading {
       color: #6c757d;
       margin: 20px 0;
     }
   </style>
 </head>
 <body>


   <div class="container-fluid p-0">
    
     <!-- 称賛メッセージ -->
     <div id="praiseMessage"></div>


     <h6 class="text-muted text-start mb-2"><i class="bi bi-list-check"></i> 今日のタスク</h6>
    
     <!-- ローディング -->
     <div id="loading">
       <div class="spinner-border spinner-border-sm text-primary" role="status"></div>
       読み込み中...
     </div>


     <!-- タスクリスト -->
     <div class="list-group shadow-sm" id="taskList">
       <!-- ここにGASから取得したタスクが挿入されます -->
     </div>


     <!-- 更新ボタン -->
     <button class="btn btn-sm btn-outline-secondary mb-3" onclick="loadTasks()">
       <i class="bi bi-arrow-clockwise"></i> リスト更新
     </button>


     <!-- 挨拶エリア -->
     <div class="footer-greeting">
       <span id="currentDate"></span><br>
       <span id="greetingText">Let's do this!</span>
     </div>


     <!-- クラッカー音源 (フリー素材の効果音URLを設定しています) -->
     <!-- 音が鳴らない場合は、srcを別の有効なmp3 URLに変更してください -->
     <audio id="crackerSound" src="https://actions.google.com/sounds/v1/cartoon/clown_horn.ogg"></audio>


   </div>


   <!-- ライブラリ -->
   <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></script>


   <script>
     // 起動時
     window.onload = function() {
       updateDateGreeting();
       loadTasks(); // タスク読み込み開始
     };


     const praiseWords = ["天才!", "神対応!", "偉すぎる!", "仕事早っ!", "優勝!", "完璧!"];


     /**
      * GASからタスク一覧を取得して表示
      */
     function loadTasks() {
       document.getElementById('loading').style.display = 'block';
       document.getElementById('taskList').innerHTML = '';


       google.script.run
         .withSuccessHandler(renderTaskList)
         .withFailureHandler(function(e){ alert('読み込み失敗: ' + e); })
         .getTasks();
     }


     /**
      * タスクリストの描画
      */
     function renderTaskList(tasks) {
       const listEl = document.getElementById('taskList');
       document.getElementById('loading').style.display = 'none';


       if (tasks.length === 0) {
         listEl.innerHTML = '<div class="text-muted p-3">タスクがありません<br><small>A列にタスクを入力してください</small></div>';
         return;
       }


       tasks.forEach(function(task) {
         const item = document.createElement('div');
         item.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center task-item';
         if (task.isCompleted) {
           item.classList.add('completed');
         }
        
         // HTMLの組み立て
         item.innerHTML = `
           <span class="text-truncate" style="max-width: 180px;">${task.name}</span>
           ${task.isCompleted
             ? '<span class="badge bg-secondary">完了</span>'
             : `<button class="btn btn-success btn-check-task" onclick="completeTask(${task.rowIndex}, this)">
                  <i class="bi bi-check-lg"></i>
                </button>`
           }
         `;
         listEl.appendChild(item);
       });
     }


     /**
      * タスク完了時の処理
      * @param {number} rowIndex - スプレッドシートの行番号
      * @param {Element} btnElement - 押されたボタン要素
      */
     function completeTask(rowIndex, btnElement) {
       // 1. UIを先に更新(サクサク感のため)
       const listItem = btnElement.closest('.task-item');
       listItem.classList.add('completed');
       btnElement.parentElement.innerHTML = '<span class="badge bg-success animate__animated animate__bounceIn">Done!</span>';


       // 2. 演出開始
       celebrate();


       // 3. GASに保存
       google.script.run.markTaskAsComplete(rowIndex);
     }


     /**
      * お祝い演出(音+紙吹雪+メッセージ)
      */
     function celebrate() {
       // 音を鳴らす
       playSound();
      
       // 紙吹雪
       fireConfetti();
      
       // メッセージ表示
       showRandomPraise();
     }


     function playSound() {
       const audio = document.getElementById('crackerSound');
       audio.currentTime = 0; // 再生位置を先頭に
       audio.volume = 0.5;    // 音量調整
       audio.play().catch(e => console.log("音声再生エラー(ブラウザ制限など):", e));
     }


     function fireConfetti() {
       var count = 200;
       var defaults = { origin: { y: 0.7 } };


       function fire(particleRatio, opts) {
         confetti(Object.assign({}, defaults, opts, {
           particleCount: Math.floor(count * particleRatio)
         }));
       }


       fire(0.25, { spread: 26, startVelocity: 55 });
       fire(0.2, { spread: 60 });
       fire(0.35, { spread: 100, decay: 0.91, scalar: 0.8 });
       fire(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 });
       fire(0.1, { spread: 120, startVelocity: 45 });
     }


     function showRandomPraise() {
       const messageEl = document.getElementById('praiseMessage');
       messageEl.textContent = praiseWords[Math.floor(Math.random() * praiseWords.length)];
       messageEl.classList.remove('animate__animated', 'animate__rubberBand');
       setTimeout(() => messageEl.classList.add('animate__animated', 'animate__rubberBand'), 10);
     }


     function updateDateGreeting() {
       const now = new Date();
       document.getElementById('currentDate').textContent = now.toLocaleDateString('ja-JP');
     }
   </script>
 </body>
</html>
PAGE TOP