👌

子ども向け学習アプリ開発ログ:勉強時間タイマーを実装する

に公開

子供向けなので、長時間利用を防ぐために 1セッションの学習時間制限 を導入しました。
「勉強開始」ボタンを押すとタイマーが動き、一定時間で終了する仕組みです。


前回

  • ダッシュボードの学習状況を API経由で実データ読み込み
  • 学習進捗を可視化するための KPI 定義

今回

  • 勉強時間タイマーを実装する

① TimeLimitSetting テーブル

ユーザーごとに「1セッションの学習時間」を保存するためのテーブルを作成しました。
デフォルトは20分に設定しています。

model TimeLimitSetting {
  id                String   @id @default(cuid())
  userId            String   @unique
  user              User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  minutesPerSession Int      @default(20) // デフォルト20分
  updatedAt         DateTime @updatedAt

  @@index([userId])
}

② 実装方針

最初は「ログインしたら自動的にセッション開始(autoStart)」も考えましたが、
実際には 「勉強開始ボタン」を押して明示的に開始 する仕様にしました。

フローは以下の通りです👇

  1. ユーザーが「勉強開始」ボタンを押す
  2. TimerProviderstart() が呼ばれる
  3. 内部で開始時刻を記録、残り時間を初期化
  4. requestAnimationFrame で残り時間をカウントダウン
  5. 0 になったら自動で終了状態(idle)に戻る

③ TimerProvider(React Context)

勉強時間の状態をグローバルで管理するために React Context を利用しました。

管理する状態

  • durationMs : セッション制限時間(ms)
  • remainingMs : 残り時間(ms)
  • status : "idle" | "running" | "paused"

提供するメソッド

  • setDurationMin(m) : 制限時間の変更
  • start() : タイマー開始
  • pause() : 一時停止
  • resume() : 再開
  • reset() : 初期化

④ 実際のコード

"use client";

import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";

type Status = "idle" | "running" | "paused";
type TimerCtx = {
  status: Status;
  durationMs: number;
  remainingMs: number;
  setDurationMin: (m: number) => void;
  start: () => void;
  pause: () => void;
  resume: () => void;
  reset: () => void;
};

const Ctx = createContext<TimerCtx | null>(null);

export default function TimerProvider({ children }: { children: React.ReactNode }) {
  const [durationMs, setDurationMs] = useState(20 * 60_000);
  const [remainingMs, setRemainingMs] = useState(durationMs);
  const [status, setStatus] = useState<Status>("idle");

  const startAtRef = useRef<number | null>(null);
  const pausedAccumRef = useRef<number>(0);
  const rafIdRef = useRef<number | null>(null);

  // APIから初期設定を読み込み
  useEffect(() => {
    (async () => {
      const res = await fetch("/api/settings/time-limit");
      const j = await res.json();
      const minutes = Number(j.minutesPerSession ?? 20);
      setDurationMs(minutes * 60_000);
      setRemainingMs(minutes * 60_000);
    })();
  }, []);

  // rAFループで残り時間を更新
  useEffect(() => {
    if (status !== "running") {
      if (rafIdRef.current != null) {
        cancelAnimationFrame(rafIdRef.current);
        rafIdRef.current = null;
      }
      return;
    }

    //staus == "running"の時に動作
    const tick = () => {
      if (startAtRef.current == null) return;

      const now = performance.now();
      const elapsed = (now - startAtRef.current) + pausedAccumRef.current; 
      const newRemaining = Math.max(durationMs - elapsed, 0);
      setRemainingMs(newRemaining);

      if (newRemaining === 0) {
        setStatus("idle");
        startAtRef.current = null;
        pausedAccumRef.current = 0;
        rafIdRef.current = null;
        return;
      }

      rafIdRef.current = requestAnimationFrame(tick);
    };

    rafIdRef.current = requestAnimationFrame(tick);
    return () => rafIdRef.current && cancelAnimationFrame(rafIdRef.current);
  }, [status, durationMs]);

  // --- 制御メソッド ---
  const setDurationMin = (m: number) => {
    const ms = Math.max(1, Math.floor(m)) * 60_000;
    setDurationMs(ms);
    if (status !== "running") setRemainingMs(ms);
  };

  const start = () => {
    if (status === "running") return;
    setStatus("running");
    startAtRef.current = performance.now();
    pausedAccumRef.current = 0;
    setRemainingMs(durationMs);
  };

  const pause = () => {
    if (status !== "running") return;
    const now = performance.now();
    if (startAtRef.current != null) {
      const elapsedSinceStart = now - startAtRef.current; 
      pausedAccumRef.current += elapsedSinceStart;  //累計時間
      const left = Math.max(0, durationMs - pausedAccumRef.current);
      setRemainingMs(left);
    }
    setStatus("paused");
  };

  const resume = () => {
    if (status !== "paused") return;
    startAtRef.current = performance.now(); //再開時点をnowで記録
    setStatus("running");
  };

  const reset = () => {
    setStatus("idle");
    setRemainingMs(durationMs);
    startAtRef.current = null;
    pausedAccumRef.current = 0;
    if (rafIdRef.current != null) {
      cancelAnimationFrame(rafIdRef.current);
      rafIdRef.current = null;
    }
  };

  const value = useMemo<TimerCtx>(
    () => ({ status, durationMs, remainingMs, setDurationMin, start, pause, resume, reset }),
    [status, durationMs, remainingMs]
  );

  return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}

export const useTimer = () => {
  const ctx = useContext(Ctx);
  if (!ctx) throw new Error("useTimer must be used within a TimerProvider");
  return ctx;
};

⑤ 動作イメージ

例えば 10分制限の場合:

  1. start() → 残り時間 = 600秒
  2. 3分後に pause() → 残り時間 = 420秒
  3. 休憩してから resume() → 420秒から再開
  4. さらに 2分後に pause() → 残り時間 = 300秒
  5. 最後まで進めば 0 になって自動終了

👉 「一時停止 → 再開」を何度繰り返しても正しく時間が減っていきます。


悩んだこと

  • UIの「勉強開始ボタン → start()」はまだ仮の実装段階
  • タイマー終了時に StudySession.endedAt をAPI経由で保存する必要がある
  • 秒数を「分:秒」形式で表示するフォーマッタ関数も追加予定
  • 最初は 「残り時間を保存してそこから減らす」 シンプルな方法を考えたが、実際には 累積経過時間で管理する方式 の方が正確で、何度停止・再開してもズレが出にくい。

次回やること

  • 公開 / 下書き切り替え機能
  • Firebase Storage での画像アップロード
  • UI/デザインの補強

Discussion