🐙
子ども向け学習アプリ開発ログ:ダッシュボードに学習進捗を表示する
子供向けの学習アプリを制作しています。
子供向けとはいえ、自分の子供が楽しく勉強できるように工夫して作っており、その過程をログとして残しています。
前回
- 学習カード用のダミーデータを作成
- 最小限のUI(LearningCard)を実装
- 学習用データベースを設計
今回
- ダッシュボードの学習状況を API経由で実データ読み込み
- 学習進捗を可視化するための KPI 定義
①学習ログ用テーブル
学習進捗を計測するため、以下の最小モデルを追加しました。
-
StudySession:学習時間を計測(進行中=
endedAt
なし、終了時にdurationSec
確定) - StudyEvent:学習イベントを記録(カード表示 / 発音 / クイズ回答など)
model StudySession {
id String @id @default(cuid())
userId String
startedAt DateTime
endedAt DateTime?
durationSec Int?
}
enum StudyAction { open, tts, quiz_start, quiz_answer_ok, quiz_answer_ng, quiz_end }
enum Lang { ja, ko }
model StudyEvent {
id String @id @default(cuid())
userId String
wordId String
action StudyAction
lang Lang
createdAt DateTime @default(now())
}
② KPIの定義
KPI(Key Performance Indicator)は「どれだけ勉強したか」を示す指標です。
- todaySessions:今日開始したセッション
- monthSessions:今月開始したセッション
- allSessions:全期間のセッション
- todayWordsDistinct:今日学習したユニーク単語数
- totalWordsDistinct:全期間のユニーク単語数
👉 時間系は StudySession
、語彙系は StudyEvent
を集計。
👉 日付境界は必ず JST(Asia/Tokyo) を基準に計算。
③ JST対応の時間計算関数
データベースはUTC時間で保存されています。
ダッシュボード表示に合わせるには JST境界で切ってUTCに変換 する必要があります。
npm i date-fns date-fns-tz
// lib/time.ts
import { toZonedTime, fromZonedTime } from "date-fns-tz";
import { startOfMonth } from "date-fns";
const TZ = "Asia/Tokyo";
// 今日(JST)の範囲をUTCに変換
export function getJstDate(date: Date = new Date()) {
const z = toZonedTime(date, TZ);
const startLocal = new Date(z.getFullYear(), z.getMonth(), z.getDate(), 0, 0, 0, 0);
const endLocal = new Date(z.getFullYear(), z.getMonth(), z.getDate(), 23, 59, 59, 999);
return {
start: fromZonedTime(startLocal, TZ),
end: fromZonedTime(endLocal, TZ),
};
}
// 今月(JST)の開始をUTCに変換
export function jstMonthStart(date: Date = new Date()) {
const z = toZonedTime(date, TZ);
return fromZonedTime(startOfMonth(z), TZ);
}
④ ダッシュボード集計関数
export async function getDashboardData(userId: string, locale: "ja" | "ko" = "ja") {
const { start, end } = getJstDate();
const monthStart = jstMonthStart();
const [
todaySessions, monthSessions, allSessions,
todayWordsDistinct, totalWordsDistinct,
] = await Promise.all([
prisma.studySession.findMany({ where: { userId, startedAt: { gte: start, lte: end } } }),
prisma.studySession.findMany({ where: { userId, startedAt: { gte: monthStart } } }),
prisma.studySession.findMany({ where: { userId } }),
prisma.studyEvent.findMany({ where: { userId, createdAt: { gte: start, lte: end } }, distinct: ["wordId"] }),
prisma.studyEvent.findMany({ where: { userId }, distinct: ["wordId"] }),
]);
const secToday = sumSeconds(todaySessions);
const secMonth = sumSeconds(monthSessions);
const secTotal = sumSeconds(allSessions);
return {
today: { seconds: secToday, wordCount: todayWordsDistinct.length },
monthly: { seconds: secMonth },
total: { seconds: secTotal, wordCount: totalWordsDistinct.length },
};
}
学んだこと
- これまで Prisma を触ったらすぐに
migrate
していたが、先にformat
とvalidate
を挟むことで、スキーマのタイプミスやリレーション不整合を事前に防げることに気づいた。 -
package.json
にスクリプトを追加しておくと毎回の手順が短くなり便利。
{
"scripts": {
"pr:fmt": "prisma format",
"pr:check": "prisma validate",
"pr:mig": "prisma migrate dev"
}
}
使い方メモ(短縮コマンド)
npm run pr:fmt # スキーマの整形(DB変更なし)
npm run pr:check # スキーマ検証(DB変更なし)
npm run pr:mig -- --name add-studyevent-word-fk # 開発DBに適用+Client再生成
npm run
で 追加引数を渡すときは--
が必要。
本番はprisma migrate deploy
を使うのが定石。
次回やること
- 勉強時間タイマー機能
Discussion