【アプリ開発日記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を追加した日にちが今日と一致すれば復習に追加します。
すると…
無事反映されました! ただ、今のままだとすべて学習中になってしまっています。なので
「ステータスが0だが演習日が前日以前なら新規に追加」
「問題を再度解いたらステータスが変化(演習時の処理)」
を追加すれば…
これで今日の復習問題数がどれくらいあるか、一目でわかるようになりました!
このまま、下にボタンを付けて、学習中の問題だけをパッと解けたら便利ですね。ということで作りましょう。
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」を取得できるのです。
最後に、結局「…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年もよいお年を! ではでは!