本当なら君たちは手を挙げなければならない

とあるハンズオンにて

講師「わかりましたか？」

受講者「しーん」

数名はうなずいている

先に進んで良いのか悪いのか判りません。
これをサポートするショートケーキを作成した話です。

※ショートケーキについては、こちらをお読みください。
　https://jflute.hatenadiary.jp/entry/20180223/mastercurrent

---
この記事は[リンク情報システム](https://www.lis.co.jp)の勝手に始める[「2020新春アドベントカレンダー Tech Connect」](https://qiita.com/dgkz/items/5131349d56354b33ee97)のリレー記事です。
engineer.hanzomon のグループメンバによってリレーされています。

本日は大伍がお届けします。

---

# サービス概要
ハンズオンや勉強会などにおいて、
受講者が理解度をポチポチし、
講師が聞かなくても理解を確認できるサービスです。
（サービス公開はいずれ）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/b1a9a962-20c3-600f-237d-cedcae828132.png)

全体をざっくり図にすると以下の感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/673d2410-ffd6-e4b2-140d-bb33af8f3ea8.png)

# 技術要素
FirebaseとNuxtは、プロトを作成する際の近年ベストチョイスなのでは？と思っています。

#### Firebase
　HostingとFirestoreを利用。
　Spark（無料）プラン。

#### Nuxt
　最近Nuxtづいているので一択。

#### その他
　・GoogleのOAuth
　　　受講者のポチポチをカウントするため、Googleアカウントでログインしてもらいます。
　・Bootstrap
　　　すごくBootstrap感がでています。

# 画面とソース
### ログイン画面

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/35a8201d-d723-a80b-cc11-2b32d5b3317e.png)

- ボタンイベントにて ```firebase.auth().signInWithRedirect()``` をコールすると、Googleの認証画面が表示され、認証後にこちらに戻ってきます。
- 認証の結果は```onAuthStateChanged()```で通知されます。認証成功の場合はアプリ側で次画面を表示させます。

<details><summary>ソース</summary><div>

```html
<template>
  <b-container class="container">
    <b-row>
      <b-col class="center">
        <h1 class="title">KAIGI-Feedbacker</h1>
      </b-col>
    </b-row>
    <br />

    <b-row>
      <b-col md="4" />
      <b-col md="4" class="center">
        主催者と参加者の橋渡しをするサービスです
        <br />
        <br />
        <b-button v-show="nowProc === false" @click="loginGoogle" block variant="outline-primary" size="lg">
          Googleでログイン
        </b-button>
      </b-col>
      <b-col md="4" />
    </b-row>
  </b-container>
</template>

<script>
import firebase from '@/plugins/firebase'

export default {
  components: {},
  middleware: [],

  data() {
    return {
      nowProc: true
    }
  },

  created() {},

  mounted() {
    // 認証状態が変化した場合に呼ばれる
    firebase.auth().onAuthStateChanged((user) => {
      // ログインしていたら次画面を表示
      if (user) {
        // Firebaseの固有ユーザIDを保持
        this.$store.commit('setUser', { uid: user.uid })

        // ルーム参加・作成画面を表示
        this.$router.push({ name: 'start' })
        return
      }

      this.nowProc = false
    })
  },

  methods: {
    // Googleアカウントで認証
    loginGoogle() {
      this.nowProc = true
      firebase.auth().signInWithRedirect(new firebase.auth.GoogleAuthProvider())
    }
  }
}
</script>
```
</div></details>

### ルーム参加・作成画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/6a053d03-b9e4-325f-ab6e-7ca2391a784e.png)

- 受講者が参加するルームの入力、講師はルームの作成を行う画面です。
- 講師がルームを作成する際は、最初に表示するタイトルとして「準備中」を設定しています。
- ルームの作成ついでに、24時間が経過したルームデータを削除します。

<details><summary>ソース</summary><div>

```html
<template>
  <b-container class="container">
    <b-row>
      <b-col class="center">
        <h1 class="title">KAIGI-Feedbacker</h1>
      </b-col>
    </b-row>
    <br />

    <b-row>
      <b-col md="3" />
      <b-col md="6">
        主催者から聞いたルーム名を入力します<br />
        <b-form-input v-model="joinRoomName" placeholder="参加するルーム名" size="lg" autocomplete="off" />
        <div class="err">{{ errors.joinRoomName }}</div>
      </b-col>
      <b-col md="3" />
    </b-row>
    <br />

    <b-row>
      <b-col md="4" />
      <b-col md="4">
        <b-button @click="joinClick" block variant="outline-primary" size="lg">参加</b-button>
      </b-col>
      <b-col md="4" />
    </b-row>
    <br />

    <hr />
    <br />
    <b-row>
      <b-col md="3" />
      <b-col md="6">
        主催者の方はこちらでルームを作成してください<br />
        <b-form-input v-model="roomName" placeholder="ルーム名" size="lg"></b-form-input>
        <div class="err">{{ errors.roomName }}</div>
      </b-col>
      <b-col md="3" />
    </b-row>
    <br />

    <b-row>
      <b-col md="4" />
      <b-col md="4">
        <b-button @click="addRoomClick" block variant="outline-primary" size="lg" autocomplete="off">
          ルームを作成する
        </b-button>
      </b-col>
      <b-col md="4" />
    </b-row>
  </b-container>
</template>

<script>
import firebase from '@/plugins/firebase'
const db = firebase.firestore()

export default {
  components: {},
  middleware: [],

  data() {
    return {
      joinRoomName: '',
      roomName: '',
      errors: {
        joinRoomName: '',
        roomName: ''
      }
    }
  },

  created() {},

  mounted() {},

  methods: {
    clearError() {
      this.errors.joinRoomName = ''
      this.errors.roomName = ''
    },

    // オーディエンス参加
    joinClick() {
      this.clearError()
      if (this.joinRoomName === '') {
        this.errors.joinRoomName = 'ルーム名を入力してください'
        return
      }

      // ルームの存在チェックを行い、オーディエンス画面を表示する
      const that = this
      const docRef = db.collection('rooms').doc(this.joinRoomName)
      docRef
        .get()
        .then((doc) => {
          if (doc.exists) {
            that.$store.commit('setRoom', { roomName: that.joinRoomName })
            that.$router.push({ name: 'audience' })
          } else {
            that.errors.joinRoomName = '入力のルームは存在しません'
          }
        })
        .catch((error) => {
          console.error('Error getting document:', error)
        })
    },

    // 主催者のルーム作成
    addRoomClick() {
      this.clearError()
      if (this.roomName === '') {
        this.errors.roomName = 'ルーム名を入力してください'
        return
      }

      // 古いデータを削除
      this.removeOldRoom()

      // ルームを作成して、管理画面を表示する
      const that = this
      db.collection('rooms')
        .doc(this.roomName)
        .set({
          roomName: this.roomName,
          active: {
            title: '準備中'
          },
          createdAt: firebase.firestore.FieldValue.serverTimestamp()
        })
        .then(() => {
          this.$store.commit('setRoom', { roomName: that.roomName })
          this.$router.push({ name: 'manage' })
        })
        .catch((error) => {
          console.error('Error writing document: ', error)
        })
    },

    // 24時間前のデータを検索して削除
    removeOldRoom() {
      const delDt = new Date()
      delDt.setHours(delDt.getHours() - 24)
      db.collection(`rooms`)
        .where('createdAt', '<', delDt)
        .get()
        .then(function(querySnapshot) {
          querySnapshot.forEach(function(doc) {
            db.collection(`rooms`)
              .doc(doc.id)
              .delete()
          })
        })
        .catch(function(error) {
          console.error('Error getting documents: ', error)
        })
    }
  }
}
</script>
```

</div></details>

### フィードバック画面（受講者用）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/b47eaffb-f1df-1e8f-5675-74af41ea7333.png)

- 受講者用の画面です。
- 講師が576個のポイントをすごい勢いで話してくるので「待って」をポチります。
- 自分の理解をポチった場合、Firestoreの自分のデータを一度削除して、登録しなおします。
- これは講師側の画面にて集計する際に、削除⇒デクリメント、登録⇒インクリメントという形で処理を単純化するためとなります。
- 講師側がタイトルを更新した際、その通知イベントを受けて、本画面のタイトルの書き換えと理解の選択状態を初期状態（理解中）に戻します。

<details><summary>ソース</summary><div>

```html
<template>
  <b-container class="container">
    <b-row>
      <b-col class="center">
        <a href="/" class="link">
          <h1 class="title hand">KAIGI-Feedbacker</h1>
        </a>
      </b-col>
    </b-row>

    <b-row>
      <b-col md="3" />
      <b-col md="6" class="center">
        <h1>{{ roomName }}</h1>
      </b-col>
      <b-col md="3" />
    </b-row>
    <hr />

    <b-row>
      <b-col md="3" />
      <b-col md="6" class="center">
        <h2>{{ activeTitle }}</h2>
      </b-col>
      <b-col md="3" />
    </b-row>
    <br />

    <b-row>
      <b-col md="4" />
      <b-col md="4">
        <div @click="stateClick('1')" class="hand">
          <span v-show="nowState === '1'" class="badge badge-pill badge-danger aud--state-badge">✔</span>
          <span v-show="nowState !== '1'" class="badge badge-pill badge-light aud--state-badge">✔</span>
          <img src="/images/state1.svg" class="state-mng" />
          <span class="state--text">分かった！</span>
        </div>
        <div @click="stateClick('2')" class="hand">
          <span v-show="nowState === '2'" class="badge badge-pill badge-danger aud--state-badge">✔</span>
          <span v-show="nowState !== '2'" class="badge badge-pill badge-light aud--state-badge">✔</span>
          <img src="/images/state2.svg" class="state-mng" />
          <span class="state--text">理解中</span>
        </div>
        <div @click="stateClick('3')" class="hand">
          <span v-show="nowState === '3'" class="badge badge-pill badge-danger aud--state-badge">✔</span>
          <span v-show="nowState !== '3'" class="badge badge-pill badge-light aud--state-badge">✔</span>
          <img src="/images/state3.svg" class="state-mng" />
          <span class="state--text">待って！</span>
        </div>
      </b-col>
      <b-col md="4" />
    </b-row>
  </b-container>
</template>

<script>
import firebase from '@/plugins/firebase'
const db = firebase.firestore()

export default {
  components: {},
  middleware: [],

  data() {
    return {
      roomName: '',
      activeTitle: '',
      uid: '',
      nowState: '2'
    }
  },

  created() {},

  mounted() {
    this.roomName = this.$store.state.room.roomName
    this.uid = this.$store.state.user.uid

    // 最初のstateを2で更新
    this.updateState('2')

    // タイトル更新の受信
    const that = this
    db.collection('rooms')
      .doc(this.roomName)
      .onSnapshot((doc) => {
        // タイトル最新化
        that.activeTitle = doc.data().active.title

        // stateの初期化
        this.nowState = '2'
        that.updateState(this.nowState)
      })
  },

  methods: {
    // stateクリック
    stateClick(state) {
      // 現在の同じstateの場合は何もしない
      if (state === this.nowState) {
        return
      }

      this.updateState(state)
    },

    // ステートの更新
    updateState(state) {
      this.nowState = state

      // 一旦削除してからstateを追加（データ更新の通知受信側でのカウントを楽にするため）
      db.collection(`rooms/${this.roomName}/audience`)
        .doc(this.uid)
        .delete()
      const doc = db.collection(`rooms/${this.roomName}/audience`).doc(this.uid)
      doc.set(
        {
          state
        },
        { merge: true }
      )
    }
  }
}
</script>
```

</div></details>

### ダッシュボード画面（講師用）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/272860/b1a9a962-20c3-600f-237d-cedcae828132.png)

- 講師用の画面です。
- 各受講者がポチッた理解度が集計表示されます。
- 皆「待って」と言っていますが、どんどん進めます。
- タイトルの変更時はFirestoreのデータを更新し、その通知を受けた受講者側の画面も更新されます。

<details><summary>ソース</summary><div>

```html
<template>
  <b-container class="container">
    <b-row>
      <b-col class="center">
        <a href="/" class="link">
          <h1 class="title">KAIGI-Feedbacker</h1>
        </a>
      </b-col>
    </b-row>
    <br />

    <b-row>
      <b-col md="3" />
      <b-col md="6" class="center">
        <h1>{{ roomName }}</h1>
      </b-col>
      <b-col md="3" />
    </b-row>
    <hr />

    <b-row>
      <b-col md="3" />
      <b-col md="6" class="center">
        <b-form-input v-model="title" @keydown.enter="nextClick"
          placeholder="お話しのタイトル" size="md" autocomplete="off" />
        <br />
        <img @click="nextClick" src="/images/icon-shita.svg" class="icon-shita hand" />
      </b-col>
      <b-col md="3" />
    </b-row>
    <hr />

    <b-row>
      <b-col md="3" />
      <b-col md="6" class="center">
        <h2>{{ activeTitle }}</h2>
        <br />
        <b-row>
          <b-col cols="4">
            <StateCountBox :state-count="stateCount['1']" img-src="/images/state1.svg" />
            分かった！
          </b-col>
          <b-col cols="4">
            <StateCountBox :state-count="stateCount['2']" img-src="/images/state2.svg" />
            理解中
          </b-col>
          <b-col cols="4">
            <StateCountBox :state-count="stateCount['3']" img-src="/images/state3.svg" />
            待って！
          </b-col>
        </b-row>
        <div class="float--clear" />
      </b-col>
      <b-col md="3" />
    </b-row>
    <div id="footer" />
  </b-container>
</template>

<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.2.3/velocity.min.js"></script>

<script>
import firebase from '@/plugins/firebase'
import StateCountBox from '../components/state-countbox'
const db = firebase.firestore()

export default {
  components: {
    StateCountBox
  },
  middleware: [],

  data() {
    return {
      roomName: '',
      title: '',
      activeTitle: '',
      stateCount: {}
    }
  },

  created() {},

  mounted() {
    if (!this.$store.state.room) {
      this.$router.push({ name: 'start' })
      return
    }
    this.roomName = this.$store.state.room.roomName

    const that = this

    // アクティブタイトルの初期値取得
    const docRef = db.collection('rooms').doc(this.roomName)
    docRef
      .get()
      .then((doc) => {
        if (!doc.exists) {
          that.$store.commit('setRoom', {})
          that.$router.push({ name: 'start' })
          return
        }

        that.activeTitle = doc.data().active.title
      })
      .catch((error) => {
        console.error('Error getting document:', error)
      })

    // オーディエンスの件数の受信
    const query = db.collection(`rooms/${this.roomName}/audience`)
    query.onSnapshot((snapshot) => {
      snapshot.docChanges().forEach((change) => {
        let val = 1
        if (change.type === 'removed') {
          val = -1
        }

        // 当該stateがなければゼロで始める
        const state = change.doc.data().state
        const nowTotal = that.stateCount[state] || 0

        that.$set(that.stateCount, state, nowTotal + val)
      })
    })
  },

  methods: {
    nextClick(event) {
      if (!this.title || this.title === '') {
        return
      }

      // 日本語入力中のEnterキー操作は無効にする
      if (event.keyCode && event.keyCode !== 13) {
        return
      }

      const that = this
      db.collection('rooms')
        .doc(this.roomName)
        .set({
          roomName: this.roomName,
          active: {
            title: this.title
          }
        })
        .then(() => {
          that.activeTitle = that.title
          that.title = ''
        })
        .catch((error) => {
          console.error('Error writing document: ', error)
        })
    }
  }
}
</script>
```

</div></details>

<details><summary>集計値表示コンポーネント（StateCountBox）のソース</summary><div>

```html
<template>
  <div class="state--countbox">
    <img :src="imgSrc" class="state-mng" />
    <div class="state--countbox-count">
      <h3>
        <span class="badge badge-danger">
          {{ stateCount }}
        </span>
      </h3>
    </div>
  </div>
</template>

<script>
export default {
  components: {},
  middleware: [],

  props: {
    imgSrc: {
      type: String,
      required: true,
      default: ''
    },
    stateCount: {
      type: Number,
      required: false,
      default: 0
    }
  },

  data() {
    return {}
  },

  created() {},

  mounted() {}
}
</script>
```
</div></details>

---

という感じでショートケーキをまた作りました。
ショートケーキを沢山作ったらチャーハンになったりしますかね？

---

[リンク情報システム株式会社](https://www.lis.co.jp)では**一緒に働く仲間を[随時募集](https://www.lis.co.jp/employ_top)**しています！
また、**お仕事のご依頼**、**ビジネスパートナー様も募集**しております。[お気軽にご連絡](https://www.lis.co.jp/inquiry)ください。
[Facebookはこちら](https://ja-jp.facebook.com/lis.co.jp/)です。