見出し画像

【アプリ開発日記51週目】復習問題のアラーム・お気に入り機能を実装する

 おつかれさまです、ちゅーりんです! いよいよブラウザ上で公開できたので、エラーを修正しながら欲しい機能を付け足しています。

 これから「インタラクティブ」(ユーザーが問題を作成できるなど)に次ぐもう一つの核「最後の演習から一定期間が経過すると再度通知・その問題だけ解き直せる」を実装。

ということでさっそく始めていきます!

1,最終演習日から何日経過したかを問題ごとに取得する

 まず、表示の基になる「」を取得します。ということで、新たな処理を作成しました。

export const restDays = created_at => {
  const now = new Date();
  const solved = new Date(created_at);
  // console.log(solved)
  const rest = now.getTime() - solved.getTime();
  const days = Math.floor(Math.floor(rest / 1000 / 60 / 60 / 24) % 7);
  // console.log('days : ',days)
  return days;
}

 Django API でデータをとってくると、日付型は「2022-12-22T15:00:29.587447+09:00」のように文字列(jsonなので)で返ってきますが、「new Date(created_at)」のようにすると直接jsの日付型に直してくれるみたいですね。便利。

 これで、演習履歴を登録した日付を入れると「今は問題を解いてから○日経った」という数値を取得できます。あとは、この数字に応じて表示を変えてあげれば良さそうです!

日にち&日にちに応じて色変化

 とりあえず反映までこぎつけました。が……

2,今日解いて未復習の問題数・以前解いて今日が復習日の問題数を表示する

 ここで、重大な欠陥に気づきます。「何度目に解いたか」がわからないのです。つまり「毎回4日後」に解き直しとなってしまい、「1日-7日-1ヶ月後」に解き直せない。また、例えば2日経過した時に再度解けば復習のタイミングはどうなるのか? という問題もあります。

 そこで、今回は

  • データベースに「演習日」+「○日経過したら復習」というカラムを追加

  • ○日、は最初は0からはじまる(=その日中にもう一度解く必要あり)

  • 0の状態は『学習中』に入る。解くと「1」の状態になり、学習中の数字が減る。もしその日中に解き直さなければ、翌日に持ち越される(『新規』枠に追加されるようにする)

  • 1の状態で新たに解き直すと、正解したら1の次の7に更新され、7日後に『今日の復習問題』で表示される。不正解の場合このステータスは1にリセットされる。30日後でも同様

  • (ただし正解か不正解は自分で選択できる)

まとめると

  • 『新規』 :その日中に解き直さなかった(持ち越しされた)履歴数(ステータスが-1)

  • 『学習中』:その日解いて、まだ解き直していない履歴数(ステータスが0)

  • 『復習』 :演習日と○日のカラムから、今日が解き直しの日である履歴数(ステータスが1以降)

  • これで残りの数を毎日0になるようにしていくと、自然に色分けバーの青い部分が増えていく

【データベース案のデメリット】

  • データが増えた時、読み込みが遅くなる(セッションのユーザーIDが必要なので、getStaticPropsで事前にレンダリングできない)

  • 履歴読み込み問題を解決すれば同時に解決する

  • いずれにしても、データを取得してからメイン画面の問題数分「演習日と○日のカラムから、今日が復習の日に当たるか」を計算する必要がある

 こう見ると、一番速く映ってほしいメイン画面が一番重くなりそう。どうにかgetStaticPropsで解決できないかと考えましたが、仮に「〇〇.com/main/userId」というユーザー別のメイン画面を作ったとして、他の人もそのURLを直打ちすると見れてしまうんですよね。

『情報が表示される前にリダイレクトされる』処理を作れば解決するんでしょうか。root画面にも『「〇〇.com」にアクセスした時点でセッションからuserIdを取得できれば「〇〇.com/main/userId」に遷移する』という処理もセットで作ればなんとかなるかもしれません。いや…セッションからID取得するまでの一瞬、事前レンダリングの内容映っちゃうか。

 …うーん、このあたりは時間がある時に考えてみます。

 少し本題から逸れてしまいましたが、履歴に新たなカラムを追加して「今日解いて未復習の問題数・以前解いて今日が復習日の問題数を表示」していきます!

 まずはデータベース追加。

 続いてメイン画面の履歴取得時にこの「interval」を取得し、0であれば学習中、0だが演習日が今日以外なら新規、1以上で且つcreated_atにintervalを追加した日にちが今日と一致すれば復習に追加します。

 すると…

 無事反映されました! ただ、今のままだとすべて学習中になってしまっています。なので

  1. 「ステータスが0だが演習日が前日以前なら新規に追加」

  2. 「問題を再度解いたらステータスが変化(演習時の処理)」

を追加すれば…

 これで今日の復習問題数がどれくらいあるか、一目でわかるようになりました!

 このまま、下にボタンを付けて、学習中の問題だけをパッと解けたら便利ですね。ということで作りましょう。

3,今日の復習問題数のみを回せるボタンを作る

 何が悩みかって、ハマるUIが思いつかない。上記のデザインシンプルで気に入っているので、どうにか汚したくないんですよね、、、

 と言ってても進まないので、ええいどうとでもなれ!とりあえずボタンと機能を実装します。ごめんよUI。

 中身の処理は、以前セクション画面などで作成したボタンとほとんど同じです。問題リストを作成しつつ、localSessionに保存しています。

 そしてスタートボタンを押せば…

 無事復習対象の問題に遷移できました!

4,お気に入り機能を実装する

 ここからはおまけです。といってもお気に入りのノート、セクション、問題(ブックマーク)のデータベースを新たに作成するので、やや慎重に進めていきますが。

 まずお気に入り用のデータベースの作成です!

class QuizFav(models.Model):
    user    = models.ForeignKey(settings.AUTH_USER_MODEL,verbose_name="ユーザー",on_delete=models.SET_NULL,null=True)
    book    = models.ForeignKey(Book,verbose_name="ノート",on_delete=models.SET_NULL,null=True)
    section = models.ForeignKey(Section,verbose_name="セクション",on_delete=models.SET_NULL,null=True)
    quiz    = models.ForeignKey(Quiz,verbose_name="問題",on_delete=models.SET_NULL,null=True)
    updated_at = models.DateTimeField('更新日',default=timezone.now)
    created_at = models.DateTimeField('作成日',default=timezone.now)

    def __str__(self):
        return f'{self.book.title} - {self.section.title}'

 無事追加できたので、今度はUIからもお気に入り登録できるようにしていきます。これも今までの処理をコピーして少し変えるだけで良さそうですね。作成したボタンを押すと…

+ボタンを押すと「追加」ボタンが出てくる。デザインまで使いまわしなので、そこは少し考えないと、、、

 保存されました! あとは、上部にお気に入り登録されたものを並べ、下部(try next)にはそれ以外のbooksを並べればそれっぽくなってきますね。本当はリコメンドにしたいのですが、本筋でなく手間もかかりそうなので、今回はパスの方向で。

 お気に入りのデータを取ってきて、次に下で並べる時それ以外が並ぶようにすれば…

 登録したノートだけが上に並びました! Try nextにはそれ以外のノートが並んでいますね。

(補足)useSWRで複数のmutateをまとめてしたい

 ちなみに、今回は一つのページ内で複数のmutateを使いました。useSWR版のプロバイダーを_app.jsで設定する必要はありますが、公式ドキュメントを参考にしています(日本語だからありがたい!)。

 実装内容がこちら。

--- _app.js ---

import '../styles/globals.css'
import '../styles/index.scss'
import '../styles/book_list.scss'
import '../styles/quiz.scss'
import { SessionProvider } from 'next-auth/react'
import StateContextProvider from '../context/StateContext'
import { SWRConfig } from 'swr'

function MyApp({ Component, pageProps: { session, ...pageProps } }) {
  return (
    <SessionProvider session={session}>
      <StateContextProvider>
        <SWRConfig value={{ provider: () => new Map() }}>
          <Component {...pageProps} />
        </SWRConfig>
      </StateContextProvider>
    </SessionProvider>
  )
}

export default MyApp

--- index.js ---(SWRのみ抜粋)

import { useSWRConfig } from 'swr'


export default function Home({ booksWithResults, booksWithCounts }) {

  let userId = '';
  const { data: session } = useSession(); // sessionからデータ取得
  if (session) userId = session.userId;

  function useMatchMutate() {
    const { cache, mutate } = useSWRConfig()
    return (matcher, ...args) => {
      if (!(cache instanceof Map)) throw new Error('matchMutateは、キャッシュプロバイダーがMapインスタンスである必要があります')
  
      const keys = []
      console.log('matcher : ',matcher)
      console.log('...args : ',...args)
      console.log('cache : ',cache)
  
      for (const key of cache.keys()) {
        if (matcher.includes(key)) keys.push(key)
      }
      console.log('keys : ',keys)

  
      const mutations = keys.map((key) => mutate(key, ...args))
      console.log(Promise.all(mutations))
      return Promise.all(mutations)
    }
  }

  function Button() {
    const matchMutate = useMatchMutate()
    const urls = [
      `${process.env.NEXT_PUBLIC_RESTAPI_URL}api/book/`,
      `${process.env.NEXT_PUBLIC_RESTAPI_URL}api/book-fav-by-user/userid=${userId}`,
      `${process.env.NEXT_PUBLIC_RESTAPI_URL}api/result-by-book-user/userid=${userId}`,
    ]
    return <button onClick={() => matchMutate(urls)}>
      更新
    </button>
  }

  return (
    <Layout title='QuizHub'>
      
      <div className='flex flex-col md:flex-row mt-16'>
        <div className='contents__left w-full mr-8'>
          <Status />

          {Button()}

          <BookList booksWithResults={booksWithResults} />

          <BookSearch booksWithCounts={booksWithCounts} />
        </div>

        <div className='contents__right'><SvgQuiz /></div>
      </div>

    </Layout>
  )
}

export async function getStaticProps() {
(略)
}

結果(ボタン押下時)

matcher :  http://127.0.0.1:8000/api/book/

...args :  http://127.0.0.1:8000/api/book-fav-by-user/userid=3 http://127.0.0.1:8000/api/result-by-book-user/userid=3

cache :  
Map(4) {'http://127.0.0.1:8000/api/book/' => {…}, 'http://127.0.0.1:8000/api/result-by-user?userid=3' => {…}, 'http://127.0.0.1:8000/api/quiz-by-user?userid=3' => {…}, 'http://127.0.0.1:8000/api/bookcomp-by-user?userid=3' => {…}}
※ この中にキャッシュ(もともとのデータ)も入っています

keys :  
(4) ['http://127.0.0.1:8000/api/book/', 'http://127.0.0.1:8000/api/result-by-user?userid=3', 'http://127.0.0.1:8000/api/quiz-by-user?userid=3', 'http://127.0.0.1:8000/api/bookcomp-by-user?userid=3']


matchMutate(/^\/api\//) の場合
matcher :  /^\/api\//
...args : 
cache, keysは同じ

matchMutate() の場合
matcher :
...args :
cache, keysは同じ

 無事データをまとめて更新できました! 

【!ポイント!】

  • 子コンポーネントも含め、そのページのuseSWRをすべて更新(わざわざURLを指定する必要もない)

  • つまり、更新したいURLだけmatchMutate()に入れてif文を使えば、その部分だけ更新できる(負荷も最小限に抑えられる。下記に詳細)

  • もちろんSWRなので、更新されるのは変更箇所だけ。画面が一瞬真っ白になることもない

 …でもmatcherとかargsとかなんや?ってところだったので、useSWRConfigで調べるとmutateのページがヒット!

 なるほど。ようやくこの「グローバルミューテート」っていう言葉の意味が分かってきました。

 そもそも useSWR の mutate は key が対応付けられているため、key の指定は必要ない。けど、今回みたいに「const { cache, mutate } = useSWRConfig()」をすると、もともとのデータ「キャッシュ」と「キーを中に入れればどれにも対応できるmutate」を取得できるのです。

グローバルのミューテート API(useSWRConfig
で定義したmutate):どんなキーに対してもミューテートできる。mutate()の引数にkey(=url)が必要。

バウンドミューテート API(useSWRで定義したmutate)
:対応する SWR フックのデータのみミューテートできる。mutate()でOK

https://swr.vercel.app/ja/docs/mutation

 最後に、結局「…args」の意味はよくわかりませんでした。なのでこれは予測に過ぎないのですが、matchMutate()内に複数の引数を入れた場合「1つ目はmatcherに、2つ目以降はスプレッド構文の…argsにまとめて入る」という、別にuseSWRも何も関係ない、ただ適当に用意されたものだと思います。でもおかげで、スプレッド構文もはじめてしっかり理解できたような気がします!

 つまり、さきほどにも触れたように「指定したurlだけmutateしたい!」という時に使うみたいですね。例えば

--- matcherに /^\/api\// をいれたとき ---

<button onClick={() => matchMutate(/^\/api\//)}>

for (const key of cache.keys()) {
  if (matcher.test(key)) {
    keys.push(key)
  }
}


--- matcherに URLの配列 をいれたとき ---

<button onClick={() => matchMutate(urls)}>

for (const key of cache.keys()) {
  if (matcher.includes(key)) {
    keys.push(key)
  }
}

といった感じです!

 いやSWRのロゴのほうが気になるんですが!

ゴゴゴゴゴ……

おわりに

 少しずつ作りたかったイメージに近づいています! 何より実際に自分でも重宝できるので有り難いですね。この調子でさあ覚えていこう…といった矢先、肝心なことを忘れていました。

「そもそも映像授業受けてない!」

「解く問題がない!」

 そう、今入れている問題は、以前大学入試用に作成した問題をそのまま引っ張ってきたのです。つまり、英単語を覚えるには抜群なのですが、医学の問題はすっからかん。だからといって、問題を1から入力していても国試が終わる…ということで、次回は問題作成を楽にしていきます。

 まだぎこちないけど、バックエンドもAPIもフロントエンドも公開できるようになって、いよいよ紙とペンのようになってきた…ちょっと仲良くなれた一年な気がします!

 2023年もよいお年を! ではでは!

この記事が気に入ったらサポートをしてみませんか?