オフラインでも動く！音声入力メモ帳「タスクス」を開発しました！

# はじめに
オフラインでも動作する、スマートフォン向け 音声入力メモ帳Webアプリ 「タスクス」 を開発したので紹介します！

タグに「AmiVoiceAPI」と入っていますが、記事投稿キャンペーン「[音声認識APIを使ってみよう！](https://qiita.com/official-events/23333339b694ca71810a)」に参加するため、記載しています

いつかAmiVoiceAPIとWeb Speech APIの認識速度の比較もやってみたいなぁ、なんて思ってますが、今回の記事では扱ってません！ごめんなさい！

# まずは成果物
![Screenshot.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/19eae888-589c-b1a3-9aee-fb7526f784b7.jpeg)

実際に動かせるものをGitHub Pagesに載せてあります
こちらからご覧ください！

https://kazu-iroiro.github.io/TaskScheduler/index.html

パソコン等でご覧の方は下の二次元コードからアクセスをお願いします
<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/7b346117-eb88-b4ae-5210-c31c18205a87.png">

## 使い方
0. マイクボタンをタップすると、ブラウザのマイク入力許可などの確認画面が出てくるはずです、許可をお願いします
　
0. 画面右上のメニューから「動作確認」をタップすると、必要な権限が取得できたか確認できます
　
0. アプリ側の準備が整うと、音声入力が始まります(端末側のマイク使用中表示を頼りにすると分かりやすいです)
　
0. **【「今日」や「明日」などの相対的な日付、または「YYYY年MM月DD日」や「◯月◯日」などの絶対値的な日付】**+**【その日の予定】** を読み上げると、メモが追加されます
　例:「明日は新宿でランチを食べる」
　
0. 音声入力ができない環境にある場合は、入力ボックスに同様の内容を打ち、保存ボタンをタップすると、メモが追加されます
　
0. 新しい内容でメモを追加すると、自動で日付順に並びます

気に入っていただけたらホーム画面に追加・アプリとしてインストールしてもらえると嬉しいです！

# 使用した技術
## 言語
+ HTML
+ JavaScript
+ CSS

## API/ライブラリ
+ Web Speech API
　主要なブラウザでほぼ標準サポートされている音声認識API
　[対応状況](https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API#ブラウザーの互換性)
 
+ kuromoji.js
　形態素解析をするライブラリ
　[GitHub](https://github.com/takuyaa/kuromoji.js/)
 
+ jQuery
　画面の制御に使用
　[公式サイト](https://jquery.com/)


---

 Web Speech APIはJavaScript数行で簡単に音声認識を実装できるので本当にオススメです

登録も必要ないので、とりあえず機能だけ載せておきたい、という場合にも重宝すると思います

下のMozillaのサイトにサンプルコードがあります

https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API

---

kuromoji.jsは、Atilika社が開発したJavaで形態素解析を行うオープンソースエンジン「kuromoji」を、[Asano Takuya](https://github.com/takuyaa)さんがJavaScriptに移植したものです

辞書ファイルを配置し、数行のコードを書くだけで、簡単に日本語の文章を品詞分解できます

https://github.com/takuyaa/

---

# このWebアプリの利点

## 話しかけると要点だけまとめてくれる
形態素解析を使い、文をできるだけ簡略化するようにしています

例えば「**新宿でランチを食べる**」と話すと、「**新宿ランチ食べる**」と変換されます

## オフライン動作
iOS端末でPWAとしてインストールすると、全ての機能をオフラインで使用できます

android端末(Chrome)の場合は、音声認識機能以外をオフラインで使用できます

例えば通信制限がかかった場合や、SIMなしで運用しているサブスマホでも、好きなタイミングでいつでも利用できます

iOSとandroidで、オフラインでの音声認識ができるか違いがありますが、これはandroidが音声認識をクラウド上で処理する仕様の影響のようです

## (基本的に)外部送信なし
android端末で音声認識を使う場合(Googleのサーバーへ送信される)以外、全ての情報を端末上で処理、保存します

セキュリティ面で安心してもらえるよう、このような仕組みにしています

# どんな挙動をするの？
0. 音声認識開始ボタンが押される
↓
0. JavaScriptが発火、Web Speech APIで音声認識が開始
↓
0. Web Speech APIで得られた文章をkuromoji.jsで解析
↓
0. 日付の情報を抽出
↓
0. 助詞や副詞などの取り除いても文の意味が変わらない単語以外を抽出
↓
0. 日付の情報をキーにして抽出した内容を保存
↓
0. 保存した内容を取り出し、日付の順に整列、表示

というような順序で処理しています

# コード
主要なファイルのみ掲載しました。
[GitHub](https://github.com/kazu-iroiro/TaskScheduler)に必要なファイルはすべて置いてあるので、コードを確認したい等ありましたら、そちらからご覧ください
<details open><summary>折りたためます</summary>


```html:index.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8" />
    <title>タスクス</title>
    <link rel="stylesheet" href="style.css">
    <link rel="manifest" href="manifest.json">
    <link rel="manifest" href="manifest.webmanifest">

    <script async src="./pwacompat.min.js"></script>

    <link rel="apple-touch-icon" href="apple-touch-icon.png" sizes="180x180" />
    <link rel="icon" type="image/png" href="apple-touch-icon.png" sizes="180x180" />
    <link rel="icon" href="favicon.ico" />
</head>

<body>
    <header><span class="header_text">タスクス<span id="ke">ケ</span><span id="ji">ジ</span><span id="yu">ュ</span><span
                id="uu">ー</span><span id="ra">ラ</span></span>
        <span class="menu_text">Menu</span>
        <!-- ハンバーガーメニュー部分 -->
        <div class="nav">
            <!-- ハンバーガーメニューの表示・非表示を切り替えるチェックボックス -->
            <input id="drawer_input" class="drawer_hidden" type="checkbox">
            <!-- ハンバーガーアイコン -->
            <label for="drawer_input" class="drawer_open"><span></span></label>

            <!-- メニュー -->
            <nav class="nav_content">
                <ul class="nav_list">
                    <li class="nav_item"><a href="index.html"><img class="nav_icon" src="images/home.png">ホーム</a></li>
                    <li class="nav_item"><a href="setting.html"><img class="nav_icon" src="images/setting.png">設定</a>
                    </li>
                    <li class="nav_item"><a href="howto.html"><img class="nav_icon" src="images/howto.png">使い方</a></li>
                    <li class="nav_item"><a href="about.html"><img class="nav_icon" src="images/about.png">タスクスについて</a>
                    </li>
                    <li class="nav_item"><a href="license.html"><img class="nav_icon" src="images/license.png">利用規約</a>
                    </li>

                    <br>
                    <li class="nav_item"><a href="check.html"><img class="nav_icon" src="images/check.png">動作確認</a></li>
                    <li class="nav_item" id="InstallPwa"><a href="install.html"><img class="nav_icon" src="images/install.png">インストール</a></li>
                </ul>
            </nav>
        </div>
    </header>
    <!--ボタンを押したときに流れる音声(非表示)-->
    <textarea class="hidden" id="speech_text">お話しください</textarea>
    <!--音声合成の言語選択(非表示)-->
    <select class="hidden" id="voice-names"></select>
    <!--音声認識のスタートボタン-->
    <button id="speak-btn" class="speak-btn"><img class="speak-btn-icon" src="images/mic_icon.png"></button>


    <input type="text" id="text_box" name="text_box" class="text_box" placeholder="ここに文字を入力">
    <input type="image" id="submit_button" name="submit_button" class="submit_button" src="images/floppy.png">
    <!--横棒用-->
    <div class="line"></div>
    <!--保存データの表示場所-->
    <div class="display_data" id="display_data"></div>

    <!--jsファイルたち-->
    <script src="./kuromoji.js"></script>
    <script src="./jquery-3.5.1.min.js"></script>
    <script src="./main.js"></script>
</body>

</html>
```

```javascript:main.js
const DICT_PATH = "./dict";

// Web Speech APIにブラウザが対応しているか
if (!("speechSynthesis" in window)) {
    console.log("このブラウザには対応していません")
}

// Execute loadVoices.
loadVoices();

// 音声のリストを取得
function loadVoices() {
    let voices = speechSynthesis.getVoices();
    $("#voice-names").empty();
    voices.forEach(function (voice, i) {
        const $option = $("<option>");
        try {
            $option.val(voice.name);
            $option.text(voice.name + " (" + voice.lang + ")");

            $option.prop("selected", voice.name === "Google 日本語");
        } catch (e) { }
        $("#voice-names").append($option);
    });
}

// Chrome loads voices asynchronously.
window.speechSynthesis.onvoiceschanged = function (e) {
    loadVoices();
};

const uttr = new SpeechSynthesisUtterance();

// Set up an event listener for when the 'speak' button is clicked.
// Create a new utterance for the specified text and add it to the queue.
$("#speak-btn").click(async function () {
    uttr.text = $("#speech_text").val();
    uttr.rate = parseFloat(1.25);
    // If a voice has been selected, find the voice and set the
    // utterance instance's voice attribute.
    if ($("#voice-names").val()) {
        uttr.voice = speechSynthesis
            .getVoices()
            .filter(voice => voice.name == $("#voice-names").val())[0];
    }
    speechSynthesis.speak(uttr);
    uttr.onend = async function () {
        console.log("お話しください");
        await rec_voice();
    };
});

$("#submit_button").click(async function () {

    console.log("データを取得します");
    text_data = $("#text_box").val();
    if (text_data != "") {
        await kuromoji_kaiseki(text_data);
    }
    $("#text_box").val('');
});

window.onload = async function () {
    display_data();

    if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('serviceWorker.js')
            .then(
                function (registration) {
                    if (typeof registration.update == 'function') {
                        registration.update();
                    }
                })
            .catch(function (error) {
                console.log("Error Log: " + error);
            });
    }
}

async function rec_voice() {
    const ids = [];
    const names = [];

    const SpeechRecognition_common = window.webkitSpeechRecognition || window.SpeechRecognition;

    const recognition = new SpeechRecognition_common();

    try {
        // 日本語の数字を単語として登録する
        const grammar =
            '#JSGF V1.0 JIS ja; grammar numbers; public <numbers> =  10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 ;';
        const SpeechGrammarList =
            window.webkitSpeechGrammarList || window.SpeechGrammarList;
        const speechRecognitionList = new SpeechGrammarList();

        speechRecognitionList.addFromString(grammar, 1);
        recognition.grammars = speechRecognitionList;
    } catch (e) { }

    recognition.continuous = false;
    recognition.lang = 'ja-JP';
    recognition.interimResults = false;
    recognition.maxAlternatives = 1;




    recognition.onresult = async (event) => {
        recognition.abort();
        text_data = await suuji(event.results[0][0].transcript);
        console.log(text_data);

        await kuromoji_kaiseki(text_data);
    }

    recognition.start();
}

async function display_data() {
    var data = "";
    const collection = Object.keys(localStorage).sort().map(key => {

        var data_tmp = localStorage.getItem(key);
        data = data + '<div class="data_deco" id="' + key + '">' + '<div class="date_deco">' + key + '</div>' + data_tmp + '</div>';

    });
    let element = document.getElementById('display_data');
    element.innerHTML = '<h3>' + data + '</h3>';

    const closeIcons = document.querySelectorAll('.close-icon');
    const items = document.querySelectorAll('.item');

    // 「X」をクリックしたときの処理
    for (let j = 0; j < closeIcons.length; j++) {
        closeIcons[j].addEventListener('click', async () => {
            console.log(items[j].id);
            console.log(items[j]);


            var edit_tmp = String(localStorage.getItem(items[j].id));
            var replace_text = String(items[j].outerHTML + '<span class="close-icon">X</span><br>');
            console.log(replace_text);
            edit_tmp = edit_tmp.replace(replace_text, '');
            console.log(edit_tmp);





            if (edit_tmp == "") {
                document.getElementById(String(items[j].id)).classList.add("data_deco_box_after");
                localStorage.removeItem(items[j].id);
            } else {
                items[j].classList.add("data_deco_after");
                document.getElementById(String(items[j].id)).classList.add("data_deco_box_resize");
                localStorage.setItem(items[j].id, edit_tmp);
            }



            const sleep = waitTime => new Promise(resolve => setTimeout(resolve, waitTime));

            await sleep(300);

            items[j].remove();
            closeIcons[j].remove();
            console.log(j);

            display_data();
        });
    }

}

async function save_data(date, text_data) {
    var saved_data = null;
    if (localStorage.getItem(date)) {
        saved_data = localStorage.getItem(date);
    } else {
        saved_data = ""
    }
    try{
    localStorage.setItem(date, saved_data + '<span class="item" id="' + date + '">' + text_data + '</span><span class="close-icon">X</span><br>');
    }catch(e){
        alert("容量オーバーです。保存できませんでした。")
    }
}

async function kuromoji_kaiseki(text_data) {
    var today = new Date();

    var rearranged_text_data = "";
    var data_date = null;


    // kuromojiが対応していない日付部分の切り離し(精度が悪くなるため対応している日付語はスルー)

    var year = "";
    var month = "";
    var day = "";

    check_year = text_data.indexOf('年');
    if (check_year != -1) {
        if (text_data.search(/[0-9]/) + 3 == check_year || text_data.search(/[0-9]/) + 4 == check_year) {
            year = text_data.substring(text_data.search(/[0-9]/), check_year);
            text_data = text_data.replace(year + "年", "");
            today.setFullYear(year);
        }
    }
    check_month = text_data.indexOf('月');
    if (check_month != -1) {
        if (text_data.search(/[0-9]/) + 1 == check_month || text_data.search(/[0-9]/) + 2 == check_month) {
            month = text_data.substring(text_data.search(/[0-9]/), check_month);
            text_data = text_data.replace(month + "月", "");
            today.setMonth(month - 1);
        }
    }
    check_day = text_data.indexOf('日');
    if (check_month != -1) {
        if (text_data.search(/[0-9]/) + 1 == check_day || text_data.search(/[0-9]/) + 2 == check_day) {
            day = text_data.substring(text_data.search(/[0-9]/), check_day);
            text_data = text_data.replace(day + "日", "");
            today.setDate(day);
        }
    }
    // 年/月/日のいずれかが存在する場合はdata_dateを設定
    if (year != "" || month != "" || day != "") {
        data_date = String(today.toLocaleDateString("ja-JP", {
            year: "numeric", month: "2-digit",
            day: "2-digit"
        }));
    }

    console.log(year);
    console.log(month);
    console.log(day);

    await kuromoji.builder({ dicPath: DICT_PATH }).build(async (err, tokenizer) => {
        const tokens = await tokenizer.tokenize(text_data);// 解析データの取得
        tokens.forEach(async (token) => {// 解析結果を順番に取得する
            console.log(token);
            console.log(token.surface_form);
            if (String(token.surface_form) == "一昨々日" || String(token.surface_form) == "一昨昨日" || String(token.surface_form) == "さきおととい" || text_data.match(/1昨昨日/)) {
                date_flag = token.word_position;

                today.setDate(today.getDate() - 3);

                data_date = String(today.toLocaleDateString("ja-JP", {
                    year: "numeric", month: "2-digit",
                    day: "2-digit"
                }));

                try {
                    text_data = text_data.replace("1昨昨日", "");
                } catch (e) { }

                console.log("date");
            } else if (String(token.surface_form) == "おととい" || String(token.surface_form) == "一昨日" || text_data.match(/1昨日/)) {
                date_flag = token.word_position;

                today.setDate(today.getDate() - 2);

                data_date = String(today.toLocaleDateString("ja-JP", {
                    year: "numeric", month: "2-digit",
                    day: "2-digit"
                }));

                try {
                    text_data = text_data.replace("1昨日", "");
                } catch (e) { }

                console.log("date");
            } else if (String(token.surface_form) == "昨日") {
                date_flag = token.word_position;

                today.setDate(today.getDate() - 1);

                data_date = String(today.toLocaleDateString("ja-JP", {
                    year: "numeric", month: "2-digit",
                    day: "2-digit"
                }));

                console.log("date");
            } else if (String(token.surface_form) == "今日") {
                date_flag = token.word_position;

                today.setDate(today.getDate() - 0);

                data_date = String(today.toLocaleDateString("ja-JP", {
                    year: "numeric", month: "2-digit",
                    day: "2-digit"
                }));

                console.log("date");
            } else if (String(token.surface_form) == "明日") {
                date_flag = token.word_position;

                today.setDate(today.getDate() + 1);

                data_date = String(today.toLocaleDateString("ja-JP", {
                    year: "numeric", month: "2-digit",
                    day: "2-digit"
                }));

                console.log("date");
            } else if (String(token.surface_form) == "明後日") {
                date_flag = token.word_position;

                today.setDate(today.getDate() + 2);

                data_date = String(today.toLocaleDateString("ja-JP", {
                    year: "numeric", month: "2-digit",
                    day: "2-digit"
                }));

                console.log("date");
            } else if (String(token.surface_form) == "明々後日" || String(token.surface_form) == "明明後日" || text_data.match(/明々後日/)) {
                date_flag = token.word_position;

                today.setDate(today.getDate() + 3);

                data_date = String(today.toLocaleDateString("ja-JP", {
                    year: "numeric", month: "2-digit",
                    day: "2-digit"
                }));

                try {
                    text_data = text_data.replace("明々後日", "");
                } catch (e) { }

                console.log("date");
            } else if (String(token.surface_form) != "*" && String(token.pos) == "名詞" || String(token.pos) == "動詞" || String(token.pos) == "副詞" || String(token.pos) == "形容詞" || String(token.pos) == "感動詞" || String(token.pos_detail_1) == "終助詞" || String(token.pos_detail_1) == "接続助詞") {
                rearranged_text_data = rearranged_text_data + token.surface_form;
            }
        });
        if (data_date == null) {
            data_date = "メモ"
        }
        save_data(data_date, rearranged_text_data);
        display_data();
    });
}

// I get this program from Qiita! (https://qiita.com/t-yama-3/items/9819600cec53723472d3) Thank you!
async function suuji(text_data) {
    //定数の設定
    const suuji1 = new Set('一二三四五六七八九十百千１２３４５６７８９123456789');  // 数字と判定する文字集合
    const suuji2 = new Set('〇万億兆０0,');  // 直前の文字が数字の場合に数字と判定する文字集合
    const kans = '〇一二三四五六七八九';
    const nums = '０１２３４５６７８９';
    const tais1 = '千百十';  // 大数1
    const tais2 = '兆億万';  // 大数2

    // ●関数(1) '五六七八'または'５６７８'(全角)を'5678'(半角)に単純変換する関数
    function Kan2Num(str) {
        let tmp;  // 定数kansまたはnumsを1文字ずつ格納する変数
        for (let i = 0; i < kans.length; i++) {
            tmp = new RegExp(kans[i], "g");  // RegExpオブジェクトを使用（該当文字を全て変換するため）
            str = str.replace(tmp, i);  // replaceメソッドで変換
        }
        for (let i = 0; i < nums.length; i++) {
            tmp = new RegExp(nums[i], "g");  // RegExpオブジェクトを使用（該当文字を全て変換するため）
            str = str.replace(tmp, i);  // replaceメソッドで変換
        }
        return str;
    }

    // ●関数(2) '九億八千七百六十五万四千三百'を'987654300'に変換する関数（n=1: ４桁まで計算、n=4: 16桁まで計算）
    function Kan2NumCnv(str, n) {
        // 変数の宣言（[let ans = poss = 0, pos, block, tais, tmpstr;]とまとめても良い）
        let ans = 0;  // 計算結果を格納する変数（数値型）
        let poss = 0;  // 引数strにおける処理開始位置（数値型）
        let pos;  // 引数strにおける大数（'十','百','千','万'など）の検索結果位置（数値型）
        let block;  // 各桁の数値を格納する変数（数値型）
        let tais;  // 大数を格納（文字列型）
        let tmpstr;  // 引数strの処理対象部分を一時格納する変数（文字列型）

        if (n === 1) {  // n == 1 の場合は４桁まで計算
            tais = tais1;
        } else {  // n == 4 (n != 1) の場合は16桁まで計算（16桁では誤差が生じる）
            n = 4;
            tais = tais2;
        }

        for (let i = 0; i < tais.length; i++) {
            pos = str.indexOf(tais[i]);  // indexOf関数は文字の検索位置を返す
            if (pos === -1) {  // 検索した大数が存在しない場合
                continue;  // 何もしないで次のループに
            } else if (pos === poss) {  // 検索した大数が数字を持たない場合（'千'など）
                block = 1;  // '千'は'一千'なので'1'を入れておく
            } else {  // 検索した大数が数字を持つ場合（'五千'など）
                tmpstr = str.slice(poss, pos);  // sliceメソッドは文字列の指定範囲を抽出する
                if (n === 1) {
                    block = Number(Kan2Num(tmpstr));  // 1桁の数字を単純変換（上で作成したKan2Num関数を使用）
                } else {
                    block = Kan2NumCnv(tmpstr, 1);  // 4桁の数字を変換（本関数を再帰的に使用）
                }
            }
            ans += block * (10 ** (n * (tais.length - i)));  // ans に演算結果を加算
            poss = pos + 1;  // 処理開始位置を次の文字に移す
        }

        // 最後の桁は別途計算して加算
        if (poss !== str.length) {
            tmpstr = str.slice(poss, str.length);
            if (n === 1) {
                ans += Number(Kan2Num(tmpstr));
            } else {
                ans += Kan2NumCnv(tmpstr, 1);
            }
        }
        return ans;
    }

    // ●関数(3) '平成三十一年十二月三十日'を'平成31年12月30日'に変換
    function TextKan2Num(text) {
        let ans = '';  // 変換結果を格納する変数（文字列型）
        let tmpstr = '';  // 文字列中の数字部分を一時格納する変数（文字列型）
        for (let i = 0; i < text.length + 1; i++) {
            // 次のif文で文字が数字であるかを識別（Setオブジェクトのhasメソッドで判定）
            if (i !== text.length && (suuji1.has(text[i]) || (tmpstr !== '' && suuji2.has(text[i])))) {
                tmpstr += text[i]; // 数字が続く限りtmpstrに格納
            } else {  // 文字が数字でない場合
                if (tmpstr !== '') {  // tmpstrに数字が格納されている場合
                    ans += Kan2NumCnv(tmpstr, 4);  // 上で作成したKan2NumCnv関数で数字に変換してansに結合
                    tmpstr = '';  // tmpstrを初期化
                }
                if (i !== text.length) {  // 最後のループでない場合
                    ans += text[i];  // 数字でない文字はそのまま結合
                }
            }
        }
        return ans;
    }

    return TextKan2Num(text_data);
}
```

```css:style.css
@font-face {
    font-family: 'BIZ UDGothic';
    font-weight: 700;
    src: url(BIZ_UDGothic/BIZUDGothic-Bold.ttf) format('truetype');
}

body {
    width: 100%;
    margin: 0;
    padding: 0;
}

header {
    width: 100%;
    height: 5svh;
    background-color: rgb(255, 255, 255);
    padding: 1svh 0;
    border-top: solid 0svw #00000000;
    border-bottom: solid 1svw #000000;
}

.header_text {
    font-size: 3.75svh;
    margin-left: 2svh;
    font-weight: bold;
    vertical-align: middle;
}

.hidden {
    display: none;
    visibility: hidden;
}

a {
    text-decoration: none;
    color: black;
}

.menu_text {
    color: rgb(0, 0, 0);
    position: absolute;
    top: 3.5svh;
    right: 2svw;
    margin-left: 5svw;
    font-size: 5svw;
}

/*hamburger menu*/
.drawer_hidden {
    display: none;
}

/* ハンバーガーアイコンの設置スペース */
.drawer_open {
    display: flex;
    height: 15svw;
    width: 15svw;
    justify-content: center;
    align-items: center;
    position: absolute;
    z-index: 100;
    right: 0;
    top: -1svh;
    /* 重なり順を一番上にする */
    cursor: pointer;
}

/* ハンバーガーメニューのアイコン */
.drawer_open span,
.drawer_open span:before,
.drawer_open span:after {
    content: '';
    display: block;
    height: 0.75svw;
    width: 6.25svw;
    border-radius: 0.75svw;
    background: #000000;
    transition: 0.5s;
    position: absolute;
}

/* 三本線の一番上の棒の位置調整 */
.drawer_open span:before {
    bottom: 1svh;
}

/* 三本線の一番下の棒の位置調整 */
.drawer_open span:after {
    top: 1svh;
}

/* アイコンがクリックされたら真ん中の線を透明にする */
#drawer_input:checked~.drawer_open span {
    background: rgba(255, 255, 255, 0);
}

/* アイコンがクリックされたらアイコンが×印になように上下の線を回転 */
#drawer_input:checked~.drawer_open span::before {
    bottom: 0;
    transform: rotate(45deg);
}

#drawer_input:checked~.drawer_open span::after {
    top: 0;
    transform: rotate(-45deg);
}

/* メニューのデザイン*/
.nav_content {
    width: 100%;
    height: 100%;
    top: 7.5svh;
    left: 100%;
    /* メニューを画面の外に飛ばす */
    background: #fff;
    transition: .5s;
    position: fixed;
    z-index: 100;
    background-color: rgba(255, 255, 255, 0.90);
    border-left: solid 0.5svw #000000;
    border-top: solid 0svw #000000;
}

/* メニュー黒ポチを消す */
.nav_list {
    margin-top: 2svh;
    margin-left: 0svw;
    list-style: none;
    font-size: 7svw;
}

.nav_icon {
    width: 7svw;
    height: 7svw;
    margin-right: 1svw;
}

/* アイコンがクリックされたらメニューを表示 */
#drawer_input:checked~.nav_content {
    left: 22.5svw;
    /* メニューを画面に入れる */
}

.nav {
    /*margin-left: 85svw;*/
    z-index: 101;
}

.text_box {
    position: absolute;
    top: 9.5svh;
    left: 2svw;
    width: 60svw;
    height: 7.5svh;
    background-color: rgb(255, 255, 255);
    font-size: 5svw;
    border: solid 0.5svw #000000;
    border-radius: 2svw;
}

.submit_button {
    position: absolute;
    top: 10svh;
    right: 22svw;
    width: 7svh;
    height: 7svh;
    background-color: rgb(255, 255, 255);
    border: solid 0svw #000000;
    border-radius: 2svw;
}

@media (min-height:1660px) {
    .text_box {
        width: 52.5svw;
    }

    .submit_button {
        right: 24svw;
    }

    .speak-btn {
        border-radius: 7.5svw;
        border: solid 0 #000000;
    }
}

@media (min-width: 980px) {
    .text_box {
        top: 8.5svh;
    }

    .submit_button {
        right: 19.5svw;
        top: 10.5svh;
    }
}

@media (max-width: 555px) {
    .speak-btn {
        width: 15svw;
        height: 15svw;
        border-radius: 7.5svw;
        border: solid 0svw #000000;
        background-color: rgb(255, 255, 255);
        position: absolute;
        top: 9.5svh;
        right: 3svw;
    }

    .speak-btn-icon {
        width: 10svw;
        height: 10svw;

        position: absolute;
        top: 1svh;
        right: 2.1svw;
        z-index: 99;
    }
}

@media (min-width: 556px) {
    .speak-btn {
        width: 9.25svh;
        height: 9.25svh;
        border-radius: 9.25svh;
        border: solid 0svw #000000;
        background-color: rgb(255, 255, 255);
        position: absolute;
        top: 9svh;
        right: 3svw;
    }

    .speak-btn-icon {
        width: 7.5svh;
        height: 7.5svh;

        position: absolute;
        top: 1svh;
        right: 1svw;
        z-index: 99;
    }
}

.line {
    position: absolute;
    border: solid 0.125svh rgba(188, 188, 188, 0.8);
    border-radius: 0.125svh;
    top: 18.6svh;
    left: 1.25svw;
    width: 97.5svw;
    height: -1svh;
}

.display_data {
    width: 90svw;
    height: 75svh;
    background-color: rgb(255, 255, 255);
    position: absolute;
    top: 17svh;
    font-size: 4svw;
    overflow: scroll;
    overflow-x: hidden;
    padding: 3svw;
    margin: 2svw;
    margin-top: 2svh;
    border-style: inherit;
}

.data_deco {
    border: solid 0.5svw #000000;
    border-radius: 1svw 4svw 4svw 4svw;
    margin-bottom: 6svw;
    text-align: center;
    padding-bottom: 5svw;
    font-size: 5svw;
    font-weight: bold;
    font-family: 'BIZ UDGothic', sans-serif;
    margin-top: -3svw;
}

.date_deco {
    border-right: solid 0.5svw #000000;
    border-bottom: solid 0.5svw #000000;
    border-bottom-right-radius: 1svw;
    margin-bottom: 2svw;
    min-width: 30svw;
    width: fit-content;
    max-width: 40svw;
    vertical-align: middle;
    text-align: center;
    font-size: 5svw;
    font-weight: normal;
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}

.close-icon {
    cursor: pointer;
    position: absolute;
    right: 10svw;
    opacity: .05;
}

.close-icon:hover {
    opacity: .7;
}

.data_deco_after {
    animation: fade-out 0.3s forwards;
}

.data_deco_box_resize {
    animation: fade-out-resize 0.1s forwards;
}

.data_deco_box_after {
    animation: fade-out-box 0.3s forwards;
    animation: fade-out-delete 0.4s forwards;
}

@keyframes fade-out {
    100% {
        opacity: 0;
        display: none;
    }

    0% {
        opacity: 100%;
    }
}

@keyframes fade-out-box {
    100% {
        opacity: 0;
        /*display: none;*/
        /*height: 0svh;*/
    }

    0% {
        opacity: 100%;
        height: auto;
    }
}

@keyframes fade-out-delete {
    100% {
        opacity: 0;
        display: none;
        height: -1svh;
    }

    0% {
        /*opacity: 100%;*/
    }
}

@keyframes fade-out-resize {
    100% {
        height: fit-content;
    }

    0% {}
}

/* スクロールの幅の設定 */
.display_data::-webkit-scrollbar {
    width: 2svw;
    height: 2svw;
}

/* スクロールの背景の設定 */
.display_data::-webkit-scrollbar-track {
    border-radius: 2svw;
    background: rgba(110, 108, 108, 0.2);
}

/* ドラックできるスクロール部の設定 */
.display_data::-webkit-scrollbar-thumb {
    border-radius: 2svw;
    background: #000000;
}

#ra {
    animation: fade-out-title 0.3s forwards;
    animation-delay: 1.5s;
}

#uu {
    animation: fade-out-title 0.3s forwards;
    animation-delay: 1.6s;
}

#yu {
    animation: fade-out-title 0.3s forwards;
    animation-delay: 1.7s;
}

#ji {
    animation: fade-out-title 0.3s forwards;
    animation-delay: 1.8s;
}

#ke {
    animation: fade-out-title 0.3s forwards;
    animation-delay: 1.9s;
}

@keyframes fade-out-title {
    100% {
        opacity: 0;
        display: none;
    }

    0% {
        opacity: 100%;
    }
}

@media (orientation: landscape) {
    /* 画面が横向きの場合 */
}

.help {
    top: 10svh !important;
    height: 82svh !important;
}
```

</details>

# おわりに
プログラムの挙動やデザインなど、細部までこだわって作りました
ぜひ一度「タスクス」を使っていただけると嬉しいです

https://kazu-iroiro.github.io/TaskScheduler/index.html

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/7b346117-eb88-b4ae-5210-c31c18205a87.png">

いいね、コメントいただけると大変喜びます
最後まで読んでいただきありがとうございました！