コード.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>