👌
子ども向け学習アプリ開発ログ:勉強時間タイマーを実装する
子供向けなので、長時間利用を防ぐために 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)」も考えましたが、
実際には 「勉強開始ボタン」を押して明示的に開始 する仕様にしました。
フローは以下の通りです👇
- ユーザーが「勉強開始」ボタンを押す
-
TimerProvider
のstart()
が呼ばれる - 内部で開始時刻を記録、残り時間を初期化
-
requestAnimationFrame
で残り時間をカウントダウン - 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分制限の場合:
-
start()
→ 残り時間 = 600秒 - 3分後に
pause()
→ 残り時間 = 420秒 - 休憩してから
resume()
→ 420秒から再開 - さらに 2分後に
pause()
→ 残り時間 = 300秒 - 最後まで進めば
0
になって自動終了
👉 「一時停止 → 再開」を何度繰り返しても正しく時間が減っていきます。
悩んだこと
- UIの「勉強開始ボタン → start()」はまだ仮の実装段階
- タイマー終了時に
StudySession.endedAt
をAPI経由で保存する必要がある - 秒数を「分:秒」形式で表示するフォーマッタ関数も追加予定
- 最初は 「残り時間を保存してそこから減らす」 シンプルな方法を考えたが、実際には 累積経過時間で管理する方式 の方が正確で、何度停止・再開してもズレが出にくい。
次回やること
- 公開 / 下書き切り替え機能
- Firebase Storage での画像アップロード
- UI/デザインの補強
Discussion