🐙

子ども向け学習アプリ開発ログ:ダッシュボードに学習進捗を表示する

に公開

子供向けの学習アプリを制作しています。
子供向けとはいえ、自分の子供が楽しく勉強できるように工夫して作っており、その過程をログとして残しています。

前回

  • 学習カード用のダミーデータを作成
  • 最小限の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 していたが、先に formatvalidate を挟むことで、スキーマのタイプミスやリレーション不整合を事前に防げることに気づいた。
  • 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