Asana APIを使ってタスクでビンゴをしてみた

※この記事は[Asana コミュニティ Advent Calendar 2024](https://adventar.org/calendars/9994)の12/10の記事です。
※元ネタは[GRIT Project](https://grit-project.org/info/889/)のクリエイターのものからインスパイアし簡易的な形でのテスト実装となります。

# Asanaのタスクを楽しくこなす
## タスクが貯まるとやる気が…
日々タスクをこなしてる皆様。次々と他人からタスクが振られて疲弊していっていますか？
そんな時にどう楽しくタスクをこなすかをAPIを使ってタスクでBINGOをするという形で行っていくものをつくりました。
ん？って思った人もデモ見てください。

### DEMO

<iframe width="560" height="315" src="https://www.youtube.com/embed/7Sh5AvBeiV4" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

※本来は左側がタブレットなどで常駐するものとかを表示するイメージです。連動を見せるためにあえてAsanaのタスク一覧を表示していますがビンゴ中は見る必要はありません。

## 構成
### ざっくり
今回はブラウザ上だけで動作するようにしておりサーバサイドは使っていません
1. Asana APIを使って自分のタスクを取得し、タスクをやるべき時間順にソートします
※今回のやるべき順≒日付ソート＋時間が入ってるものを優先
2. BINGOの配置をパターン化し（センターを最優先にあくようにするなど）ランダムに選択し画面を描画します
※１日の最初のみにランダムで選び次の日までパターンを保持するようにしています
3. タスクをクリックすると完了のAPIでAsanaと連動し、独自にBingoしたか判定

### 問題点
APIを直接叩いているのとユーザの情報を取得するために、大事な大事なPersonal access tokenを直接書かせるという流れで大変不安なアプリです。
今回の仕組みだとサーバサイドでtoken管理しても結局登録はWeb上でtoken貼り付けが必要になるのでこのあたり含めてPersonal access tokenの紐づけとか良さげな方法があったら教えてほしいです…。便利なんだけど結局個人アカウントに紐づくから怖いのとわけわからないことをさせる感じになるので人におすすめできません。
あとAsanaのAPIでタスク取得するとタスクが日付順にとれないので多めにとってからソートみたいな形をしていますのでタイミング次第ではタスクが多すぎると今日の分のとりこぼしがありそうで不安。

### ソース
解説はエンジニアらしくソースを見ろという流れ（汗）
ベースはCopilotがHTMLとかもろもろ書いてもらってから改造してコードを書いていますので色々雑さはご了承ください。
とりあえず作るのにほんと便利。

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Asana BINGO</title>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="main.js"></script>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 0;
        }
        li{
            text-align: left;
        }
        #loginScreen, #app {
            display: none;
            padding: 20px;
        }
        #loginScreen {
            text-align: center;
            margin-top: 7vw;
        }
        #tokenInput {
            width: 300px;
            padding: 10px;
            font-size: 16px;
        }
        .tokenInfo{
            margin-top: 7vw;
            padding: 1em;
            font-size: 0.8em;
            background-color: #f0f0f0;
        }

        #appHeader {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 20px;
            background-color: #f0f0f0;
        }
        #appHeader h1 {
            margin: 0;
            font-size: 5vw;
        }
        #loginButton {
            padding: 10px 20px;
            font-size: 16px;
        }
        #settingsButton {
            cursor: pointer;
            font-size: 24px;
        }
        .grid {
            display: grid;
            grid-template-columns: repeat(3, 1fr);
            width: 100%;
            box-sizing: border-box;
        }
        .cell {
            border: 1px solid #ccc;
            padding: 20px;
            aspect-ratio: 1/1;
            vertical-align: middle;
            cursor: pointer;
            box-sizing: border-box;
            display: flex;
            justify-content: center;
            align-items: center;
            flex-direction: column;

        }
        .cell.completed {
            background-color: #374149;
        }
        .taskname{
            font-size: 1em;
            font-weight: bold;
        }
        .taskdue{
            margin-top:0.5em;
            font-size: 0.7em;
        }
        .free{
            color:#CC9966;
            font-weight: bold;
        }
        .overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background-color: rgba(0,0,0,0.8);
            color: #fff;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 48px;
            z-index: 1000;
            color:red;
            font-weight: bold;
        }
        .notice {
            font-size: 0.8em;
            color:gray;
        }
    </style>
</head>
<body>

    <div id="appHeader">
        <h1>Asana BINGO</h1>
        <div id="settingsButton">
            <svg version="1.1" id="_x31_0" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512" style="width: 5vw; height: 5vw; opacity: 1;" xml:space="preserve">
                <style type="text/css">
                    .st0{fill:#374149;}
                </style>
                <g>
                    <path class="st0" d="M411.384,417.421v-0.22l-5.818,5.818c-10.394,10.394-23.503,17.378-38.011,20.17
                        c-4.575,0.932-9.31,1.398-13.962,1.398c-2.997,0-5.989-0.235-8.914-0.598c-0.057-0.011-0.11-0.011-0.162-0.023l-0.004,0.012
                        c-0.049-0.008-0.102-0.004-0.151-0.012v1.008H91.384V67.026h252.978v42.508l-0.023,5.59c0.008,0,0.015,0,0.023-0.003v0.155
                        c3.022-0.386,6.125-0.622,9.231-0.622c19.625,0,38.086,7.606,51.973,21.493l3.356,3.352l2.307,2.466v-0.158l0.155,0.158V0H24.362
                        v510.284l-0.004,1.561h0.004V512h387.022v-55.553l0.11-39.133L411.384,417.421z" style="fill: rgb(75, 75, 75);"></path>
                    <path class="st0" d="M475.308,249.792l-91.693-91.694c-7.993-7.989-18.697-12.413-30.022-12.413c-3.106,0-6.209,0.31-9.231,1.087
                        c-7.838,1.629-15.05,5.587-20.792,11.326c-3.102,3.026-5.662,6.519-7.602,10.318c-1.318,2.405-2.33,4.966-3.102,7.678
                        c-1.166,3.879-1.784,7.993-1.784,12.106c0,4.03,0.618,8.144,1.784,12.098c1.94,6.75,5.663,12.954,10.705,17.921l11.076,11.075
                        l7.621,7.777H217.369c-23.507,0-42.511,19.083-42.511,42.511c0,23.428,19.004,42.511,42.511,42.511h73.307l51.636,0.11
                        l-18.742,18.738c-3.102,3.03-5.662,6.519-7.602,10.318c-1.318,2.409-2.33,4.966-3.102,7.682c-1.166,3.878-1.784,7.993-1.784,12.098
                        c0,4.038,0.618,8.148,1.784,12.106c1.94,6.75,5.663,12.954,10.705,17.916c4.034,4.038,8.766,7.216,14.042,9.311
                        c2.17,0.932,4.5,1.629,6.75,2.094c0.386,0.155,0.697,0.231,1.083,0.31c2.716,0.466,5.432,0.777,8.148,0.777
                        c2.712,0,5.428-0.31,8.068-0.777c8.378-1.629,15.977-5.662,21.954-11.716l27.769-27.772l63.924-63.842
                        c7.989-7.992,12.334-18.621,12.334-29.867S483.297,257.708,475.308,249.792z" style="fill: rgb(75, 75, 75);"></path>
                </g>
                </svg>

        </div>
    </div>

    <div id="loginScreen">
        <h2>Personal access token を入力</h2>
        <input type="text" id="tokenInput" placeholder="Personal access token ">
        <br/>
        <span class="notice">※ Personal access token はローカルにのみ保存されます。<br/>セキュリティ上の理由から他者に知られないようにしてください</span>
        <br><br>
        <button id="loginButton">Start!</button>

        <div class="tokenInfo">
            以下を参考に Personal access token を取得してください。<br>
            <span class="notice">※ Asanaの権限によって取得できない場合は管理者に問い合わせください</span>
            <ol>
                <li>Asanaへブラウザでログインしてください。</li>
                <li>同一ブラウザで<a href="https://app.asana.com/0/my-apps" target="_blank" >developer console</a>へアクセスしてください。</li>
                <li>個人アクセストークン(Personal access token)で[+トークンを新規作成]をクリックしてください。</li>
                <li>トークン名は任意のわかりやすいものにし利用規約に同意し[トークンを作成]をクリックしてください。</li>
                <li>生成されたトークンをコピーし、この画面で入力してください。<br/>
                    <div class="notice">このトークンは１度しか表示されませんのでご注意ください。<br/>取得がうまくいかなかった場合は作成したものを削除し再度新規作成をしてください。</div></li>
            </ol>
            
            <a href="https://developers.asana.com/docs/personal-access-token" target="_blank">Asanaサイトのヘルプはこちら[Personal access token]</a> <br/>
        </div>
    </div>

    <div id="app">
        <div id="grid" class="grid">
            <div class="cell" id="cell1"></div>
            <div class="cell" id="cell2"></div>
            <div class="cell" id="cell3"></div>
            <div class="cell" id="cell4"></div>
            <div class="cell" id="cell5"></div>
            <div class="cell" id="cell6"></div>
            <div class="cell" id="cell7"></div>
            <div class="cell" id="cell8"></div>
            <div class="cell" id="cell9"></div>
        </div>
    </div>

    <div id="overlay" class="overlay" style="display: none;"></div>
</body>
</html>
```
```javascript:main.js

// ------------------------------------------------------------
// localstrageのキー
const asanaKey = 'asanaToken';
const gridNumberDataKey = 'gridNumberData';

// ------------------------------------------------------------
// Asana APIへアクセスするための保存するキー
let accToken = localStorage.getItem(asanaKey);
let userTaskListGId = '';

// ------------------------------------------------------------
// ビンゴ関連

// センターに期限が明確なタスクを配置するように位置を明示
let insertNumbers = [
    [5, 1, 9, 2, 3, 4, 6, 7, 8],
    [5, 2, 8, 4, 6, 9, 7, 3, 1],
    [5, 3, 7, 6, 9, 2, 8, 1, 4],
];
// ビンゴラインの状態
let checkdBingoLine = [];

// ------------------------------------------------------------
// onload
document.addEventListener('DOMContentLoaded', () => {
    if (!accToken) {
        document.getElementById('loginScreen').style.display = 'block';
        document.getElementById('app').style.display = 'none';
        document.getElementById('loginButton').addEventListener('click', () => {
            const key = document.getElementById('tokenInput').value.trim();
            if (key) {
                saveApiKey(key);
                startApp();
            } else {
                errorEvent(100);
            }
        });
    } else {
        startApp();
    }

    document.getElementById('settingsButton').addEventListener('click', () => {
        if (window.confirm('ログアウトしますか？')) {
            localStorage.removeItem(asanaKey);
            location.reload();
        }
    });

    const cells = document.querySelectorAll('.cell');

    cells.forEach(cell => {
        cell.addEventListener('click', () => {
            let taskId = cell.getAttribute('data-task-id');
            if (taskId) {
                clickTask(taskId, cell);
            }
        });
    });

});
// ------------------------------------------------------------
// アプリ起動
function startApp() {
    document.getElementById('loginScreen').style.display = 'none';
    document.getElementById('app').style.display = 'block';
    getUserTaskList();
    // fetchTasks();
}
// ------------------------------------------------------------
// APIキーを保存
function saveApiKey(key) {
    localStorage.setItem(asanaKey, key);
    accToken = key;
}
// ------------------------------------------------------------
// エラー処理
function errorEvent(code) {
    console.log('ERROR CODE: ' + code);
    switch (code) {
        case 100:
            alert('Tokenが設定されていません。');
            break;
        case 200:
        case 201:
            alert('ユーザー情報の取得に失敗しました。Tokenを再度入力してください。');
            break;
        case 250:
            alert('タスクの取得に失敗しました。Tokenを再度入力してください。');
            break;
        default:
            alert('エラーが発生しました。Tokenを再度入力してください。');
            break;
    }
    localStorage.removeItem(asanaKey);
    location.reload();
}

// ------------------------------------------------------------
// User情報取得
async function getUserData() {
    try {
        const response = await fetch('https://app.asana.com/api/1.0/users/me', {
            headers: {
                'Accept': 'application/json',
                'Authorization': 'Bearer ' + accToken
            }
        });
        const result = await response.json();
        if (response.ok) {
            return result.data;
        } else {
            errorEvent(200);
        }
    } catch (error) {
        console.log(error);
        errorEvent();
    }
}

// ------------------------------------------------------------
// タスク取得のための下準備
async function getUserTaskList() {
    try {
        const userData = await getUserData();
        const workspacesGId = userData.workspaces[0].gid;

        const response = await fetch(`https://app.asana.com/api/1.0/users/me/user_task_list?workspace=${workspacesGId}`, {
            headers: {
                'Accept': 'application/json',
                'Authorization': 'Bearer ' + accToken
            }
        });
        const result = await response.json();
        if (response.ok) {
            userTaskListGId = result.data.gid;
            getTasks();
        } else {
            errorEvent(201);
        }
    } catch (error) {
        console.log(error);
        errorEvent();
    }
}

// ------------------------------------------------------------
// タスク取得
async function getTasks() {
    try {

        let now = new Date();
        // 今日の0時0分0秒を取得
        now.setHours(0);
        now.setMinutes(0);
        now.setSeconds(0);
        now.setMilliseconds(0);

        const response = await fetch(`https://app.asana.com/api/1.0/user_task_lists/${userTaskListGId}/tasks?completed_since=${encodeURIComponent(now.toISOString())}&opt_fields=name,due_at,due_on,completed,completed_at,completed_by`, {
            headers: {
                'Accept': 'application/json',
                'Authorization': 'Bearer ' + accToken
            }
        });
        const result = await response.json();
        if (response.ok) {

            const ds = result.data.sort((a, b) => {
                if (a.due_on === b.due_on) {
                    if (a.due_at === null) {
                        return 1;
                    }
                    return (a.due_at || '').localeCompare(b.due_at || '');
                } else {
                    if (a.due_on === null) {
                        return 1;
                    }
                    return (a.due_on || '').localeCompare(b.due_on || '');
                }
            });
            displayTasks(ds.slice(0, 9));
        } else {
            errorEvent(250);

        }
    } catch (error) {
        console.log(error);
        errorEvent();
    }
}

// ------------------------------------------------------------
// タスクを描画
function displayTasks(tasks) {
    // 今日の配置を固定する
    let localGridData = JSON.parse(localStorage.getItem(gridNumberDataKey));
    let gridNumber;
    if (!localGridData || localGridData.date !== (new Date()).toLocaleDateString()) {
        gridNumber = Math.floor(Math.random() * insertNumbers.length);
        localStorage.setItem(gridNumberDataKey, JSON.stringify({ date: (new Date()).toLocaleDateString(), gridNumber: gridNumber }));
    } else {
        gridNumber = localGridData.gridNumber;
    }
    let insertNumber = insertNumbers[gridNumber];
    // なんらかしらおかしなデータだった場合は0番に強制固定
    if (!insertNumber) {
        insertNumber = insertNumbers[0];
        localStorage.setItem(gridNumberDataKey, JSON.stringify({ date: (new Date()).toLocaleDateString(), gridNumber: 0 }));
    }

    for (let i in insertNumber) {
        const posNum = insertNumber[i];
        const cell = document.getElementById('cell' + posNum);
        cell.setAttribute('data-task-id', tasks[i] ? tasks[i].gid : '');
        if (tasks[i] !== undefined) {
            const task = tasks[i];
            let taskdue = task.due_on || '期日なし';
            if (task.due_at) {
                let d = new Date(task.due_at);
                taskdue = taskdue + ' ' + d.getHours() + ':' + (d.getMinutes() < 10 ? '0' + d.getMinutes() : d.getMinutes());
            }

            cell.innerHTML = `<div class="taskname">${task.name}</div><div class="taskdue">${taskdue}</div>`;
            if (task.completed) {
                cell.classList.add('completed');
            } else {
                cell.classList.remove('completed');
            }

        } else {
            cell.innerHTML = '<span class="free">FREE</span>';
            cell.classList.add('completed');
        }
    }
    // checkBingo();
}

// ------------------------------------------------------------
// タスクをクリックしたときの処理
async function clickTask(taskId, cell) {
    try {
        const isCompleted = !cell.classList.contains('completed');
        if (isCompleted) {
            cell.classList.add('completed');
        } else {
            cell.classList.remove('completed');
        }
        checkBingo();

        const response = await fetch(`https://app.asana.com/api/1.0/tasks/${taskId}`, {
            method: 'PUT',
            headers: {
                'Authorization': 'Bearer ' + localStorage.getItem(asanaKey),
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({ data: { completed: isCompleted } })
        });
        if (response.ok) {
        } else {
            if (!isCompleted) {
                cell.classList.add('completed');
            } else {
                cell.classList.remove('completed');
            }
            alert('タスクの完了に失敗しました。');
        }
    } catch (error) {
        if (!isCompleted) {
            cell.classList.add('completed');
        } else {
            cell.classList.remove('completed');
        }
        alert('エラーが発生しました。');
    }
}

// ------------------------------------------------------------
// ビンゴ判定
function checkBingo() {
    const cells = document.querySelectorAll('.cell');
    const completed = Array.from(cells).map(cell => cell.classList.contains('completed'));
    const patterns = [
        [0, 1, 2], [3, 4, 5], [6, 7, 8], // Rows
        [0, 3, 6], [1, 4, 7], [2, 5, 8], // Columns
        [0, 4, 8], [2, 4, 6]           // Diagonals
    ];
    let bingo = false;
    for (let i in patterns) {
        let pattern = patterns[i];
        let check = true;
        for (let j in pattern) {
            if (!completed[pattern[j]]) {
                check = false;
                break;
            }
        }
        if (check && !checkdBingoLine[i]) {
            bingo = true;
            checkdBingoLine[i] = true;
        }
    }
    if (bingo) {
        showOverlay('BINGO！');
    }
    if (completed.every(status => status)) {
        showOverlay('タスク完了！');
    }
}

// ------------------------------------------------------------
// オーバーレイ表示
function showOverlay(message) {
    const overlay = document.getElementById('overlay');
    overlay.textContent = message;
    overlay.style.display = 'flex';
    setTimeout(() => {
        overlay.style.display = 'none';
    }, 3000);
}
```

## 結論
とりあえずtokenの管理が不安は強くのこりつつ強引だけど実際に体験できるものは作れた。
なんにしてもBINGOは楽しい！
BINGOのためにタスクの優先順位が近しいものはそっちにひっぱられるとか面白い現象が起こったりしそう。
あとBINGOのマスを開けたいためにタスクを細分化したくなるという可視化として良い傾向が生まれるし、そうなった場合は3x3じゃなくて4x4や5x5の表示とかにすると満足度もあがり効率が良くタスクが処理できそうな気が、する。うん。