皆さま、こんにちは

ブレイブソフトのシローです。

仕事としてサーバサイドの保守がメインになりつつ、「あれ?最近、 開発やってなくね?」ということで、
開発力を取り戻すことを目的として、とりあえずTodoアプリを作ることにしました。
その際に、色々試行錯誤したよ、という内容です。

Todoアプリとは?

Todoアプリとは、簡単にいうと「タスク管理を行うツール」です。
例として、Trelloみたいものがあります。

なぜTodoアプリ?

実際のサービスの開発では、「要件定義」や「画面設計」だの開発以外に考えることが多いです。
その点、Todoアプリの開発は機能や画面設計がとてもシンプルです。

Todoアプリの主な機能は

  • タスクを追加する
  • タスクを編集する
  • タスクを削除する
  • タスク一覧を表示する

くらいですし、

画面も基本的に一つで済みます。

なので、サービスの考案などに多くの時間をかけず、開発に注力するには向いていると思います。

また同時にフロントエンド、バックエンド、デザインについて満遍なく触れますので、総合的な開発力が身につくと思っています。

作ったもの

先に、今回作ったものを見せようと思います。
todo-notifier
一応プロジェクトのソースコードはこちらです。

機能はごく普通のTodoアプリ・・・にするのはちょっとシンプルすぎるので、
時間になると、「そろそろ期限切れだよ!!」みたいな通知をしてくれる機能をつけてみました。

要件定義

シンプルです。

  • タスクを追加する
  • タスクを編集する
  • タスクを削除する
  • タスク一覧を表示する
  • 期限切れになる前に通知する時間(30分前とか)を設定する
  • タスクが期限切れになりそうなら通知する
  • タスクの期限が切れると再通知する

画面設計

1ページだからすごく楽です。
draw.ioでも使ってささっと、ドラフトを作りました。
todo-notifier-draft
実際のアプリとは色々と違いますが、大体のイメージさえ出来ていればいいんです。

実装方法

まず、

Web系にするか、ネイティブ系にするか

Webで作成すると誰からもアクセスされちゃうので、認証機能が必要になる。そして、セキュアにするほど認証は面倒・・・
また、オンラインでなくても動作したいという考えのもと、ネイティブ系になりました。

デスクトップにするか、スマホアプリにするか

僕は使える言語がWeb系に特化しているので、スマホアプリならCordova
デスクトップアプリならElectron
になるんですが、「スマホでタスクチェックはやらないかな」という結論のもと、Electronでデスクトップアプリを作成することにしました。

Vue.jsも使ってみた

今までフロントはjQueryばかり使ってきたのですが、
フレームワークを利用した開発を積んで置けば、
可能性広がりそうだなと思い、Vue.jsを利用してみようと思いました。
また、jsでの開発を快適に行うために、babelやwebpackも利用しました。

データベースをどうするか

アプリケーションから外部のデータベースにアクセスするよりも、
アプリ内部に組み込めるような形式でデータベースを利用したい。
また、複雑な機能を持たないため、RDBにするよりも、json形式でデータを更新できるNoSQLが良いと思い。
NeDBを使うことにしました。(使い方はここ見ると良いカモです。)

以上をまとめると、

  • ネイティブのデスクトップアプリ
  • ソフトウェアフレームワークでElectronを使用
  • フロントサイドのフレームワークでVue.jsを使用
  • データベースはNeDBを使用

となりました。

プロジェクト構成

結果としてこうなっています。

|--data.db #NeDB用ファイル
|--database.js #NeDBのコントローラ
|--index.html #アプリケーション表示用HTML
|--js
| |--bundle.js #アプリケーションが利用するwebpack圧縮後のjsファイル
|--main.js #Electron起動ファイル
|--node_modules #モジュール群
|--notifier.js #通知用コントローラ
|--package.json #npm 設定
|--parametor.json #アプリ内変数
|--setting.js #アプリ内変数のコントローラ
|--src #フロント用の開発ソースファイル群(webpackで圧縮されてbundle.jsになる)
| |--app.js
| |--vue
| | |--app.vue
| | |--css
| | | |--header.css
| | | |--style.css
| | | |--task.css
| | |--js
| | | |--app.js
|--webpack.config.js #webpack設定ファイル

実装

アプリケーションの実装でコアとなった部分についてです。

Electronの起動

何はともあれ、Electron実行しないと始まりません、起動用スクリプト: main.js,レンダー用ファイル: index.htmlに以下を記述します。

main.js

const electron = require('electron')
const path = require('path')
const app = electron.app
const BrowserWindow = electron.BrowserWindow

let mainWindow = null

app.on('ready', () => {
  mainWindow = new BrowserWindow(
    {
      width: 1200,
      height: 800
    }
  )
  mainWindow.loadURL(path.join('file://', __dirname, 'index.html'))
  mainWindow.isMinimized()
})
app.on('window-all-closed', () => {
  app.quit()
})

index.html

<!doctype html>
<html>
  <head>
    <meta charset ="utf-8" />
    <title>TODO Notifire</title>
    <link rel="stylesheet" href="./css/style.css" />
  </head>
  <body>
    <div id="app">
    </div>
    <script src="./js/bundle.js"></script>
  </body>
</html>

これらのファイルを同じ階層に置いて”electron .”と実行すると実際にアプリケーションが立ち上がる用になります。

フロントの実装

index.htmlからはbundle.jsを読み込んでいます。
このファイルはsrc以下のファイルをwebpackで圧縮して出力したものです。
フロントの実装はVue.jsで行いましたが、一からデザインを作るのも面倒なので、bootstrapみたいなElement-uiという、
コンポーネントを利用して実装しました。

/src/vue/app.vue

<template>
  <div>
    <section id="header" style="background: 'gray'">
      <h1>TODO Notifire</h1>
      <el-button id="add-new-task" v-on:click="addNewTask()">New</el-button>
      <span>Notify Time: </span>
      <el-time-select
        v-model="notifyInterval"
        v-bind:picker-options="{
          start: '00:00',
          step: '00:15',
          end: '23:45'
        }"
        v-on:change="changeNotifyInterval()"
        placeholder="Select notify time">
      </el-time-select>
    </section>
    <section id="content">
      <ul id="task-list">
        <li v-for="(task, index) in taskList">
          <span v-bind:style="{ color: task.statusColor}">{{ task.statusText }}</span>
          <el-input class="task-name" type="text" v-model="task.name" v-on:change="changeTaskName(index)"/>
          <el-date-picker
            v-model="task.limitDateTime"
            v-on:change="changeLimitDateTime(index)"
            type="datetime"
            placeholder="Select limit datetime">
          </el-date-picker>
          <el-button type="primary" icon="el-icon-delete" class="remove-task" v-on:click="removeTask(index)"></el-button>
        </li>
      </ul>
    </section>
  </div>
</template>
<script src="./js/app.js"></script>

通知の実装

“node-notifier”という、node.jsによる通知機能のモジュールを使いました。(モジュールについて詳しくはここ)
これを使って、期限切れそうになると通知する用にします。

notifier.js

const NN = require('node-notifier')
module.exports = {
  notification: (title = 'Todo Notifier', message = 'Sample Notification') => {
    NN.notify({
      title,
      message,
      wait: true
    })
  }
}

これをタスクが切れそうなタイミングで発火するようにします。
タスクの締め切り時刻を監視して、締め切り前、締め切り後になったらnotifier
/src/vue/js/app.js

import { remote } from 'electron'

const notify = remote.require('./notifier')
const notification = (title, message) => {
  notify.notification(title, message)
}

const database = remote.require('./database')
const setting = remote.require('./setting')
const taskStatus = {
  todo: { text: 'Todo', color: '#39ff64' },
  notified: { text: 'Notified', color: '#ffec2a' },
  expired: { text: 'Expired', color: '#ff2a2a' }
}

export default {
  mounted: function () {
    // 締め切り直前と締め切り後のタスクを通知する
    setInterval(() => {
      let notifyInterval = (() => {
        let strs = this.notifyInterval.split(':')
        let hours = parseInt(strs[0], 10) * 60 * 60 * 1000
        let minutes = parseInt(strs[1], 10) * 60 * 1000
        return hours + minutes
      })()
      for (let task of this.taskList) {
        if (!task.name || !task.limitDateTime) continue
        let now = Date.parse((new Date()))
        let limitDateTime = Date.parse(task.limitDateTime)
        let diff = limitDateTime - now
        if (diff < notifyInterval && diff > 0) {
          if (task.notified) continue
          let title = 'そろそろタスクの締め切り前です!'
          let message = task.name
          let doc = {
            expired: true,
            statusText: taskStatus.notified.text,
            statusColor: taskStatus.notified.color
          }

          database.updateData(doc, { _id: task._id }, false, (res) => {
            if (!res) {
              console.log('Failed to change mode')
            } else {
              task.notified = true
              task.statusText = taskStatus.notified.text
              task.statusColor = taskStatus.notified.color
              notification(title, message)
            }
          })
        } else if (diff < 0) {
          if (task.expired) continue
          let title = '締め切りです!'
          let message = task.name
          let doc = {
            expired: true,
            statusText: taskStatus.expired.text,
            statusColor: taskStatus.expired.color
          }
          database.updateData(doc, { _id: task._id }, false, (res) => {
            if (!res) {
              console.log('Failed to change mode')
            } else {
              task.expired = true
              task.statusText = taskStatus.expired.text
              task.statusColor = taskStatus.expired.color
              notification(title, message)
            }
          })
        } else {
          let doc = {
            notified: false,
            expired: false,
            statusText: taskStatus.todo.text,
            statusColor: taskStatus.todo.color
          }
          database.updateData(doc, { _id: task._id }, false, (res) => {
            if (!res) {
              console.log('Failed to change mode')
            } else {
              task.expired = false
              task.notified = false
              task.statusText = taskStatus.todo.text
              task.statusColor = taskStatus.todo.color
            }
          })
        }
      }
    }, this.taskCheckInterval)
.
.
.

通知の様子

期限切れそうになタスク様子
期限切れそうになったタスク
アプリの表示
アプリの表示

最後に

新しい技術を調べて実装経験を積むためにも、Todoアプリの作成はおすすめです。

投稿者プロフィール

s.mise
eventosの開発をやっているエンジニアです。