JavaScript
todo
Application
Vanilla.JS

ネイティブのJavaScriptでToDoアプリを作ってみた

More than 1 year has passed since last update.

目的

これまでJavaScriptを使って、Webサービス上の機能単位の開発や、Webサイトの動的コンテンツの作成などは行ってきたのですが、ゼロからWebアプリケーションを作成したことがなかったのでいい加減作ってみようと思いました。
またMVCモデルなどの「表示」と「状態」を切り離した設計に最近取り組み始めたところなのでその学習の一環も担っています。

▼デモ
demo.gif

▼URL
https://kde-space.github.io/TO_DO_APP/index.html

以下は、GitHub上にも記載している内容です。

機能

  • タスクごとに優先度(3段階)、期限を設定可
  • タスクに設定した期限と、本日とを比較して状況を表示
  • タスクをステータスに応じて各々色分け
  • 入力したタスクの編集可
  • 完了済みのタスク、全てのタスクを一括削除可
  • 並べ替え可(優先度順 あるいは 期限順)
  • レスポンシブ対応
  • データの自動保存(local storage)
  • タスクの総数、タスク完遂率などを常に表示

仕様

技術選定

  • JavaScriptはフレームワークやライブラリは使用せずにピュアなJavaScript(ES2015)で作成
  • CSSはフレームワークにBootStrap4を使用

推奨環境(動作確認済み)

PC

  • windows 10
    • Google Chrome
    • Microsoft Edge

SP

  • iOS 11.3+
    • Safari
    • Google Chrome

使い方

少々長いので割愛
https://github.com/kde-space/TO_DO_APP/blob/master/README.md#使い方

ソース

html

基本的に、タスク以外はhtmlにあらかじめ記載しています。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>TO DO APP | KDE</title>
<meta name="description" content="TO DO アプリです">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="format-detection" content="telephone=no">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<link rel="stylesheet" href="css/style.css">
<script src="js/app.min.js?171029002" defer></script>
</head>
<body class="bg-light">
<div id="l-wrapper">
<div class="container-fluid">
    <header class="header">
        <h1 class="headerTitle">TO DO APP</h1>
    </header><!-- /.header -->
    <div class="row mainContainer">
        <div class="col-md-8 col-xl-9 taskContainer">
            <div id="stage"></div>
            <div class="taskFormContainer bg-dark">
                <form name="taskForm" id="js-taskForm">
                    <div class="container-fluid py-3">
                        <div class="row">
                            <div class="col-xl-5 mb-2 mb-xl-0">
                                    <div class="mb-1 small text-white">内容<span class="badge badge-danger ml-2">※必須</span></div>
                                    <div>
                                        <input type="text" name="content" class="form-control" required placeholder="タスクを入力してください">
                                    </div>
                            </div>
                            <div class="col-xl row">
                                <div class="col-sm-auto col-md-auto col-xl">
                                    <div class="mb-1 small text-white">優先度<span class="badge badge-danger ml-2">※必須</span></div>
                                    <div class="text-white">
                                        <label class="custom-control custom-radio">
                                            <input type="radio" name="priority" value="3" class="custom-control-input" required>
                                            <span class="custom-control-indicator"></span>
                                            <span class="custom-control-description"></span>
                                        </label>
                                        <label class="custom-control custom-radio">
                                            <input type="radio" name="priority" value="2" class="custom-control-input" checked>
                                            <span class="custom-control-indicator"></span>
                                            <span class="custom-control-description"></span>
                                        </label>
                                        <label class="custom-control custom-radio">
                                            <input type="radio" name="priority" value="1" class="custom-control-input">
                                            <span class="custom-control-indicator"></span>
                                            <span class="custom-control-description"></span>
                                        </label>
                                    </div>
                                </div>
                                <div class="col-auto col-sm-auto col-xl mb-3 mb-sm-0">
                                    <div class="mb-1 small text-white">期限</div>
                                    <input type="date" name="limit" class="form-control">
                                </div>
                                <div class="col-sm-auto col-lg col-xl align-self-center">
                                    <input type="submit" value="追加" class="btn btn-primary mb-1">
                                    <input type="reset" value="クリア" class="btn btn-secondary mb-1">
                                </div>
                            </div>
                        </div>
                    </div><!-- /.container -->
                </form><!-- /.taskForm -->
            </div>
        </div>

        <div class="col-md-4 col-xl-3">
            <div class="sticky-top">
                <div class="statusBox mb-4 mb-md-5" id="js-statusBox">
                    <table class="table table-bordered">
                        <tr>
                            <th>現在のタスク総数</th>
                            <td><span class="totalCount">0</span></td>
                        </tr>
                        <tr>
                            <th>残タスク数</th>
                            <td><span class="leftCount">0</span></td>
                        </tr>
                        <tr>
                            <th>完了済みタスク数</th>
                            <td><span class="completeCount">0</span><div class="taskDeleteBtn js-none" id="js-taskDeleteBtn">
                                    <a href="#" class="small">完了済みタスクを削除</a>
                                </div>
                            </td>
                        </tr>
                        <tr>
                            <th>完遂率</th>
                            <td><span class="completionRate">0</span></td>
                        </tr>
                    </table>
                </div><!-- /.statusBox -->
                <div class="sortArea mb-3">
                    <h2 class="h6 font-weight-bold">タスクの並び替え</h2>
                    <div class="btn-group w-100 sortBtns" id="js-sort">
                        <button class="btn btn-outline-secondary w-50 js-sort-priority">優先度順</button>
                        <button class="btn btn-outline-secondary w-50 js-sort-limit">期限順</button>
                    </div>
                </div><!-- /.sortArea -->
                <div class="mb-4 mb-md-5">
                    <button class="btn btn-outline-danger w-100" id="js-removeAllItem">全タスク削除</button>
                </div>
                <div>
                    <h2 class="h6 font-weight-bold mb-3">本サイトについて</h2>
                    <h3 class="h6">閲覧推奨環境</h3>
                    <div class="small">
                        <dl class="row">
                            <dt class="col-2">PC:</dt>
                            <dd class="col-10">Chrome, Edge最新版</dd>
                            <dt class="col-2">SP:</dt>
                            <dd class="col-10">iOS11.0.3以上 iOS Safari, Chrome最新版</dd>
                        </dl>
                        <p><a href="https://github.com/kde-space/TO_DO_APP" target="_blank">その他使い方などはこちら</a></p>
                    </div>
                </div>
            </div>
        </div>
    </div><!-- /.mainContainer -->

    <div class="modal" id="js-modal">
        <div class="modal-dialog modal-lg" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">編集</h5>
                    <button type="button" class="close js-modal-close" data-dismiss="modal" aria-label="Close">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <form name="taskEditForm" id="js-taskEdit">
                    <div class="modal-body">
                        <ul class="list-unstyled">
                            <li>内容 <input type="text" name="content" class="form-control" required></li>
                            <li>優先度
                                <div>
                                    <label class="custom-control custom-radio">
                                        <input type="radio" name="priority" value="3" class="custom-control-input" required>
                                        <span class="custom-control-indicator"></span>
                                        <span class="custom-control-description"></span>
                                    </label>
                                    <label class="custom-control custom-radio">
                                        <input type="radio" name="priority" value="2" class="custom-control-input" checked>
                                        <span class="custom-control-indicator"></span>
                                        <span class="custom-control-description"></span>
                                    </label>
                                    <label class="custom-control custom-radio">
                                        <input type="radio" name="priority" value="1" class="custom-control-input">
                                        <span class="custom-control-indicator"></span>
                                        <span class="custom-control-description"></span>
                                    </label>
                                </div>
                            </li>
                            <li>期限 <input type="date" name="limit" class="form-control"></li>
                        </ul>
                    </div>
                    <div class="modal-footer">
                        <input type="submit" class="btn btn-primary" value="完了">
                        <input type="reset" class="btn btn-secondary" value="クリア">
                    </div>
                </form><!-- /.taskForm -->
            </div>
        </div>
        <div class="modal-bg"></div>
    </div>
</div><!-- /.container-fluid -->
</div><!-- /#l-wrapper -->
</body>
</html>

JavaScript

/**
 * TO DO アプリ
 */
const TO_DO_APP = () => {
    const stage = document.getElementById('stage');
    const CLASS_NONE = 'js-none';
    const now = new Date();
    const lStorage = localStorage;
    const taskForm = document.getElementById('js-taskForm');

    /**
     * 汎用的に使える関数群
     */
    const utilFunc = {
        /**
         * 中身を空にする
         * @param {Node} target 中身を空にするオブジェクト
         */
        emptyHtml(target) {
            while (target.firstChild) {
                target.removeChild(target.firstChild);
            }
        },

        /**
         * ゼロパディング
         * @param {Number} Num ゼロパティングする値
         * @param {Number} digit 最終的な桁数
         * @return {String} 数値文字列
         */
        addZeroPadding(Num, digit) {
            let result = '';
            for (let i = 1; i < digit; i += 1) {
                result += '0';
            }
            return (result + Num).slice(-digit);
        },

        /**
         * 文字列エスケープ
         * @param {String} str
         */
        escapeHtml(str) {
            if (typeof str !== 'string') {
                return str;
            }
            return str.replace(/[&'`"<>]/g, (match) => ({
                '&': '&amp;',
                "'": '&#x27;',
                '`': '&#x60;',
                '"': '&quot;',
                '<': '&lt;',
                '>': '&gt;'
            }[match]));
        },

        /**
         * 空白(空文字)であるかチェック
         * @param {String} str
         */
        isBlank(str) {
            if (/\S/.test(str)) {
                return false;
            }
            return true;
        },

        /**
         * チェックが入ったラジオボタンのvalue値を返す
         * @param {NodeList} elements ラジオボタン要素群
         */
        getCheckedRadioValue(elements) {
            let result;
            Array.prototype.slice.call(elements).some((item) => {
                if (item.checked) {
                    result = item.value;
                    return true;
                }
                return false;
            });
            return result;
        }
    };

    const formattedToday = `${now.getFullYear()}-${utilFunc.addZeroPadding(now.getMonth() + 1, 2)}-${utilFunc.addZeroPadding(now.getDate(), 2)}`;

    /**
     * ユーザーエージェント判別用モジュール
     */
    const checkUserAgent = (() => {
        const _ua = navigator.userAgent.toLowerCase();
        const isIE = (_ua.includes('msie') && !_ua.includes('opera')) || _ua.includes('trident');
        const isEdge = _ua.includes('edge');
        const isChrome = _ua.includes('chrome') && !_ua.includes('edge');
        const isFirefox = _ua.includes('firefox');
        const isSafari = _ua.includes('safari') && !_ua.includes('chrome');
        const isOpera = _ua.includes('opera');

        const getUa = () => _ua;
        /**
         * デバイスの種類を判定
         * @returns {String} デバイスを表す文字列
         */
        const getDevice = () => {
            if (_ua.includes('iphone') || _ua.includes('ipod') || (_ua.includes('android') && _ua.includes('mobile'))) {
                return 'sp';
            } else if (_ua.includes('ipad') || _ua.includes('android')) {
                return 'tablet';
            }
            return 'pc';
        };

        /**
         * ブラウザ判定の結果により注意文言を取得
         * @return {Boolean | String}
         */
        const getAlertMessage = () => {
            const device = getDevice();
            if (device !== 'pc') {
                return false;
            }
            if (!isEdge && !isChrome) {
                return '<div class="alert alert-danger" role="alert">お使いのブラウザでは正しく動作しない可能性があります。<br>ChromeもしくはEdgeでご覧ください。</div>';
            }
            return false;
        };

        return {
            getUa,
            isIE,
            isEdge,
            isChrome,
            isFirefox,
            isSafari,
            isOpera,
            getDevice,
            getAlertMessage
        };
    })();

    if (checkUserAgent.isIE) {
        stage.innerHTML = '<div class="alert alert-danger" role="alert">お使いのブラウザでは動作しません。<br>ChromeもしくはEdgeでご覧ください。</div>';
    }

    // Model管理
    const model = {
        dispatcher: document.createElement('div'),
        ev: new Event('dataChange'),

        // ステート(直接外部からは参照できない)
        _stateAll: [],

        /**
         * ステートへの保存
         * @param {String} type
         * @param {*} arg
         */
        setItem(type, arg) {
            switch (type) {
            case 'add':
                this._stateAll.push(arg);
                break;
            case 'changeStatus':
                this._stateAll[arg[0]].status = arg[1] ? 'complete' : 'open';
                break;
            case 'edit': {
                const targetElement = this._stateAll[arg[0]];
                targetElement.content = arg[1];
                targetElement.priority = parseInt(arg[2], 10);
                targetElement.limit = arg[3];
                break;
            }
            case 'all':
                this._stateAll = arg;
                break;
            default:
                throw Error('The value of the argument is invalid');
            }
            this.dispatcher.dispatchEvent(this.ev);
        },

        /**
         * ステートの取得
         */
        getItem(opt_index) {
            if (opt_index >= 0 && opt_index < this._stateAll.length) {
                return this._stateAll[opt_index];
            }
            return this._stateAll;
        },

        /**
         * ソート
         */
        sortItem(ary, order) {
            this._stateAll.sort((a, b) => {
                if (!Array.isArray(ary)) {
                    return 0;
                }
                let valueA = null;
                let valueB = null;

                for (let i = 0, l = ary.length; i < l; i += 1) {
                    if (!Object.prototype.hasOwnProperty.call(a, ary[i]) ||
                        !Object.prototype.hasOwnProperty.call(b, ary[i])) {
                        return 0;
                    }
                    valueA = (typeof a[ary[i]] === 'string') ? a[ary[i]].toUpperCase() : a[ary[i]];
                    valueB = (typeof b[ary[i]] === 'string') ? b[ary[i]].toUpperCase() : b[ary[i]];

                    // 期限が空の場合は今日の日付を一時的に設定
                    if (ary[i] === 'limit') {
                        valueA = !valueA ? `${formattedToday}-1` : valueA;
                        valueB = !valueB ? `${formattedToday}-1` : valueB;
                    }

                    if (valueA < valueB) {
                        if (!Array.isArray(order) || order[i] === 'asc') {
                            return -1;
                        }
                        return 1;
                    } else if (valueA > valueB) {
                        if (!Array.isArray(order) || order[i] === 'asc') {
                            return 1;
                        }
                        return -1;
                    }
                }
                return 0;
            });
            this.dispatcher.dispatchEvent(this.ev);
        },

        /**
         * 完了済みの要素を削除
         */
        removeCompletedItem() {
            this._stateAll = this._stateAll.filter((value) => {
                if (value.status !== 'complete') {
                    return value;
                }
                return false;
            });
            this.dispatcher.dispatchEvent(this.ev);
        },

        /**
         * 全削除
         */
        deleteAllItem() {
            this._stateAll.length = 0;
            this.dispatcher.dispatchEvent(this.ev);
        }
    };

    /**
     * フォームのinput[type="date"]のvalue属性に今日の日付を設定
     */
    const setInputDateValue = (() => {
        const dateInput = taskForm.querySelector('input[type="date"]');
        const setInput = () => {
            dateInput.value = formattedToday;
        };
        if (!dateInput) {
            return false;
        }
        return setInput;
    })();

    /**
     * フォームのイベント登録
     */
    const setFormEvent = () => {
        const clearForm = () => {
            taskForm.reset();
            setInputDateValue();
        };

        const setClear = () => {
            const btnClear = taskForm.querySelector('input[type="reset"]');
            if (!btnClear) {
                return;
            }
            btnClear.addEventListener('click', (e) => {
                e.preventDefault();
                clearForm();
            });
        };

        const setAdd = () => {
            taskForm.addEventListener('submit', (e) => {
                e.preventDefault();
                const task = {
                    content: taskForm.content.value,
                    priority: utilFunc.getCheckedRadioValue(taskForm.priority),
                    limit: taskForm.limit.value,
                    status: 'open'
                };
                if (utilFunc.isBlank(task.content)) {
                    alert('内容を入力してください');
                    taskForm.reset();
                    return;
                }
                task.content = task.content.trim();
                model.setItem('add', task);
                clearForm();
            });
        };

        setClear();
        setAdd();
    };

    /**
     * 全データから各値をまとめたオブジェクト
     */
    const statusData = {
        init() {
            // 全データ
            const dataAll = model.getItem();
            // タスク総数
            this.totalCount = dataAll.length;
            // 残タスク数
            this.leftCount = (() => {
                let result = 0;
                dataAll.forEach((value) => {
                    if (value.status === 'open') {
                        result += 1;
                    }
                });
                return result;
            })();
            // 完了済みタスク数
            this.completeCount = this.totalCount - this.leftCount;
            // タスク完遂率
            this.completionRate = (() => {
                let result = null;
                result = Math.floor((this.completeCount / this.totalCount) * 100);
                if (Number.isNaN(result)) {
                    return 0;
                }
                return result;
            })();
        }
    };

    /**
     * ハイフンつなぎの日付から正確な日時を示すDateオブジェクト取得
     * @param {String} dateText 2017-09-25 のようなハイフンつなぎの文字列
     * @return {Object} Dateオブジェクト
     */
    const getCorrectDateObj = (dateText) => {
        if (!dateText) {
            return null;
        }
        return new Date(dateText.replace(/-/g, '/'));
    };

    /**
     * 期限に対しての状態を取得
     * @param {String} dateText 2017-09-25 のようなハイフンつなぎの文字列
     * @param {Date} endDateObj 期限の日付オブジェクト
     * @param {Date} nowDateObj 現在の日付けオブジェクト
     * @return {String}
     */
    const getStatusAgainstlimit = (dateText, endDateObj, nowDateObj) => {
        if (!dateText || !endDateObj) {
            return null;
        }
        const endTime = (() =>
            new Date(
                endDateObj.getFullYear(),
                endDateObj.getMonth(),
                endDateObj.getDate() + 1,
                endDateObj.getHours(),
                endDateObj.getMinutes(),
                endDateObj.getSeconds() - 1
            ))();
        if (endTime.getTime() - nowDateObj.getTime() <= 0) {
            return 'over';
        } else if (endTime.getTime() - nowDateObj.getTime() < 86400000) {
            return 'thatday';
        }
        return 'notyet';
    };

    /**
     * 期限に対して現在の状態を表すhtmlを取得
     * @param {String} status 期限に対しての現在の状態
     * @param {Date} endDateObj 期限の日付オブジェクト
     * @param {Date} nowDateObj 現在の日付けオブジェクト
     * @return {String}
     */
    const getShowHtmlAgainstlimit = (status, endDateObj, nowDateObj) => {
        switch (status) {
        case 'over': {
            return '期限が過ぎています!!';
        }
        case 'thatday': {
            return '今日が期限です!';
        }
        case 'notyet': {
            if (!endDateObj || !nowDateObj) {
                return null;
            }
            const diffTime = endDateObj.getTime() - nowDateObj.getTime();
            const diffDate = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
            return `残り${diffDate}日`;
        }
        default:
            return null;
        }
    };

    /**
     * 優先度を示す文字列を表示用に変換
     * @param {Number} priority 優先度を示す数値
     */
    const getPriorityStr = (priority) => {
        let str = '';
        switch (priority) {
        case 3:
            str = '高';
            break;
        case 1:
            str = '低';
            break;
        default:
            str = '中';
            break;
        }
        return str;
    };

    /**
     * タスクの描画
     */
    const renderTask = () => {
        // 全データ
        const dataAll = model.getItem();
        if (dataAll.length <= 0) {
            stage.innerHTML = '<div class="alert alert-info" role="alert">下記フォームからタスクを入力してください</div>';
            return;
        }
        const ul = document.createElement('ul');
        ul.className = 'mb-4 p-0';
        let html = '';

        dataAll.forEach((dataItem) => {
            const itemlimit = dataItem.limit;
            const endDay = getCorrectDateObj(itemlimit);
            const statusAgainstlimit = getStatusAgainstlimit(itemlimit, endDay, now);
            const htmlAgainstlimit = getShowHtmlAgainstlimit(statusAgainstlimit, endDay, now);
            const txt = dataItem.content;

            html += `
                <li class="card mb-2 taskItem is-${dataItem.priority}${dataItem.status === 'complete' ? ' is-complete' : ''}${statusAgainstlimit === 'over' ? ' is-over' : ''}">
                    <div class="py-1 px-3">
                        <p class="font-weight-bold my-0 taskContent">${utilFunc.escapeHtml(txt)}</p>
                    </div>
                    <div class="taskStatus px-3 small">
                        <div class="row justify-content-between align-items-center py-1 px-3">
                            <div class="">
                                <dl class="d-inline-block mb-0 mr-3">
                                    <dt class="d-inline-block">優先度</dt>
                                    <dd class="d-inline-block">${getPriorityStr(+dataItem.priority)}</dd>
                                </dl>
                                ${dataItem.limit ? `
                                <dl class="d-inline-block mb-0">
                                    <dt class="d-inline-block">期限</dt>
                                    <dd class="d-inline-block">${dataItem.limit}<span class="ml-1">【${htmlAgainstlimit}】</span></dd>
                                </dl>
                                ` : ''}
                            </div>
                            <div class="">
                                <ul class="list-inline">
                                    <li class="list-inline-item">
                                        <label class="custom-control custom-checkbox">
                                            <input type="checkbox" class="custom-control-input js-completeItem" ${dataItem.status === 'complete' ? 'checked' : ''}>
                                            <span class="custom-control-indicator border border-secondary"></span>
                                            <span class="custom-control-description">完了</span>
                                        </label>
                                    </li>
                                    <li class="list-inline-item">
                                        <button class="btn btn-secondary btn-sm js-editItem">編集</button>
                                    </li>
                                </ul>
                            </div>
                        </div>
                    </div>
                </li>
            `;
        });
        ul.innerHTML = html;
        utilFunc.emptyHtml(stage);
        stage.appendChild(ul);
    };

    /**
     * ステータスの描画
     */
    const renderStatus = () => {
        /**
         * 各要素の任意のプロパティに、対象オブジェクトの同名のプロパティの値を設定
         * @param {Node} containerNode 要素をまとめる親ノード
         * @param {Array<string>} childNodeSelectors 対象となる要素のセレクタ
         * @param {String} prop 変更する要素のプロパティ
         * @param {Object} status 変更する要素へ設定する値がまとまっているオブジェクト
         */
        const setValueSameNameProp = (containerNode, childNodeSelectors, prop, status) => {
            childNodeSelectors.forEach((childSelector) => {
                containerNode.querySelector(childSelector)[prop] = status[childSelector.slice(1)];
            });
        };

        setValueSameNameProp(
            document.getElementById('js-statusBox'),
            ['.totalCount', '.leftCount', '.completeCount', '.completionRate'],
            'textContent',
            statusData
        );
    };

    /**
     * 完了済みタスクを削除するボタンの表示切り替え
     */
    const toggleShowTaskDeleteBtn = () => {
        const taskDeleteBtn = document.getElementById('js-taskDeleteBtn');
        const taskDeleteBtnClassList = taskDeleteBtn.classList;
        if (statusData.completeCount > 0) {
            taskDeleteBtnClassList.remove(CLASS_NONE);
        } else if (!taskDeleteBtnClassList.contains(CLASS_NONE)) {
            taskDeleteBtnClassList.add(CLASS_NONE);
        }
    };

    /**
     * 完了ボタンへのイベント登録
     */
    const setCompleteEvent = () => {
        const allcompleteInputs = document.querySelectorAll('.js-completeItem');
        Array.prototype.slice.call(allcompleteInputs).forEach((completeInput, index) => {
            completeInput.addEventListener('click', (e) => {
                model.setItem('changeStatus', [index, e.currentTarget.checked]);
            });
        });
    };

    /**
     * モーダルモジュール
     */
    const modal = (() => {
        const _wrapper = document.getElementById('js-modal');
        const _modalBg = _wrapper.querySelector('.modal-bg');
        const _modalCloseBtn = _wrapper.querySelector('.js-modal-close');
        const _form = document.forms['js-taskEdit'];
        const _formInputText = _form.querySelector('input[type="text"]');
        const _formInputDate = _form.querySelector('input[type="date"]');

        /**
         * モーダルを開く
         * @public
         */
        const open = () => {
            _wrapper.style.display = 'block';
        };

        /**
         * モーダルを閉じる
         * @public
         */
        const close = () => {
            _wrapper.style.display = 'none';
        };

        /**
         * モーダル内のフォームを初期化
         * @public
         */
        const initForm = (index) => {
            const itemState = model.getItem(index);
            _formInputText.value = itemState.content;
            _formInputDate.value = itemState.limit;
            Array.prototype.slice.call(_form.querySelectorAll('input[type="radio"]')).forEach((item) => {
                if (+item.value === +itemState.priority) {
                    item.checked = true;
                } else if (item.checked) {
                    item.checked = false;
                }
            });
        };

        /**
         * モーダルを閉じるイベントを登録
         * @private
         */
        const _setCloseEvent = () => {
            [_modalBg, _modalCloseBtn].forEach((item) => {
                item.addEventListener('click', () => {
                    close();
                });
            });
        };

        /**
         * モーダル内のフォームのイベント登録
         *      編集された内容にステートを更新
         * @private
         */
        const _setEditFormEvent = () => {
            const setClear = () => {
                const btnClear = _form.querySelector('input[type="reset"]');
                if (!btnClear) {
                    return;
                }
                btnClear.addEventListener('click', (e) => {
                    e.preventDefault();
                    _form.reset();
                    _formInputDate.value = formattedToday;
                });
            };

            const setAdd = () => {
                _form.addEventListener('submit', (e) => {
                    e.preventDefault();
                    const index = modal.editBtnIndex;
                    let content = _form.content.value;
                    const priority = utilFunc.getCheckedRadioValue(_form.priority);
                    const limit = _form.limit.value;
                    if (utilFunc.isBlank(content)) {
                        alert('内容を入力してください');
                        return;
                    }
                    content = content.trim();
                    model.setItem('edit', [index, content, priority, limit]);
                    close();
                });
            };

            setClear();
            setAdd();
        };

        /**
         * 初期設定
         * @private
         */
        const _init = () => {
            _modalBg.setAttribute('style', `
                position: fixed;
                top: 0;
                right: 0;
                bottom: 0;
                left: 0;
                z-index: -1;
                background-color: rgba(0,0,0,.5);
            `);
            _setCloseEvent();
            _setEditFormEvent();
        };
        _init();

        return {
            editBtnIndex: null,
            open,
            close,
            initForm
        };
    })();

    /**
     * 編集ボタンへのイベント登録
     */
    const setEditEvent = () => {
        const allEditBtn = document.querySelectorAll('.js-editItem');
        Array.prototype.slice.call(allEditBtn).forEach((editBtn, index) => {
            editBtn.addEventListener('click', () => {
                modal.initForm(index);
                modal.open();
                modal.editBtnIndex = index;
            });
        });
    };

    /**
     * 完了済みタスクをゴミ箱へ移動
     */
    const setDeleteCompleteTask = () => {
        const deleteBtn = document.getElementById('js-taskDeleteBtn').firstElementChild;
        if (!deleteBtn) {
            return;
        }
        deleteBtn.addEventListener('click', (e) => {
            e.preventDefault();
            model.removeCompletedItem();
        });
    };

    /**
     * 並び替え
     */
    const setSortTask = () => {
        const CLASS_CONTAINER = 'js-sort';
        const sortBtns = document.getElementById(CLASS_CONTAINER).getElementsByTagName('button');
        if (sortBtns.length < 1) {
            return;
        }
        Array.prototype.slice.call(sortBtns).forEach((btn) => {
            btn.addEventListener('click', (e) => {
                e.preventDefault();
                if (e.currentTarget.classList.contains(`${CLASS_CONTAINER}-priority`)) {
                    model.sortItem(['status', 'priority', 'limit'], ['', '', 'asc']);
                } else if (e.currentTarget.classList.contains(`${CLASS_CONTAINER}-limit`)) {
                    model.sortItem(['status', 'limit', 'priority'], ['', 'asc', '']);
                }
            });
        });
    };

    /**
     * 全タスク削除
     */
    const setDeleteAllTask = () => {
        const deleteBtn = document.getElementById('js-removeAllItem');
        if (!deleteBtn) {
            return;
        }
        deleteBtn.addEventListener('click', () => {
            const res = confirm('本当に全タスクを削除して良いですか? ※復元できません');
            if (res) {
                model.deleteAllItem();
                lStorage.removeItem('app');
            }
        });
    };

    /**
     * 画面の描画アップデート
     */
    const render = () => {
        renderTask();
        renderStatus();
        toggleShowTaskDeleteBtn();
        setCompleteEvent();
        setEditEvent();
    };

    /**
     * メインコンテンツエリアの初期表示
     */
    const showFirstMainContent = () => {
        if (lStorage.app && lStorage.app !== '[]') {
            model.setItem('all', JSON.parse(lStorage.app));
        } else {
            let html = checkUserAgent.getAlertMessage() ? checkUserAgent.getAlertMessage() : '';
            html += '<div class="alert alert-info" role="alert">下記フォームからタスクを入力してください</div>';
            stage.innerHTML = html;
        }
    };

    /**
     * ディスパッチャーへのイベント登録
     */
    const setDispatchEvent = () => {
        model.dispatcher.addEventListener('dataChange', () => {
            statusData.init();
            render();
            localStorage.app = JSON.stringify(model.getItem());
        });
    };

    /**
     * 実行
     */
    const start = () => {
        setDispatchEvent();
        showFirstMainContent();
        setInputDateValue();
        setFormEvent();
        statusData.init();
        setDeleteCompleteTask();
        setSortTask();
        setDeleteAllTask();
    };
    start();
};

TO_DO_APP();

振り返り

カスタムイベントが便利

基本的な処理の流れとしては、modelオブジェクトの_stageAllプロパティにタスクの情報を入れておき、それが変更されたときに画面表示の更新を行っています。
カスタムイベントを使うことで、「状態」に変化があった時(正確には変化させる処理が実行された時)に表示更新の処理を行うことができます。
カスタムイベントを使わないと、状態変化のあとにその都度同じ表示更新の処理を書かなければいけないので非効率です。

プロトタイプ作成にはCSSフレームワークが便利

今回、CSSを書くのが面倒に感じたので初めてCSSフレームワーク(BootStrap4)を使ってみました。
最初は使い方に戸惑いましたが、ドキュメントがしっかりしているので比較的スムーズに実装できました。
クラスを当てはめていくだけで意図した見た目になっていくのは気持ちが良かったです。
こういった習作や、プロトタイプ作成には時間節約にもなるので積極的に使っていけそうです。
ただ、現在のところBootStrapはjQueryに依存しているのでそれが難点ではあります。

コードの見やすさに課題

汎用的な関数はutilFuncオブジェクトのメソッド、状態管理にはmodelオブジェクト、モーダル関連についてはmodalオブジェクトにまとめた点はまずまず分かりやすいかなと思っています。
その他については同じ階層にずらずらと関数が並んでしまったため、全体像がつかみにくくなっているように感じています。
このあたり、最近知った「SLAP(Single Level of Abstraction Principle):抽象化レベルの統一」のセオリーに従ってうまく構造化できるようにしていきたいです。
そのためには、もっと他人のコードを読むことと、いろんな設計パターンを学ぶことが必要かと感じています。

参考