🙌
【開発ログ】学習時間を正しく取得・保存しよう
実はこの記事を書いている途中でエラーが発生し、しばらく更新が止まっていました。😅
まだ完全には解決できていませんが、重複レンダリング や 状態の初期化ずれ などが原因でした。また、TimerProvider内でセッションの開始や終了の処理も直接書いていましたが、
今回はそれらを分離し、useStudySessionフックとして整理しました。これにより、sessionIdを基に各ページ(レッスン/クイズ)で学習時間を管理できるようになりました。
ひとまずここまでの流れをまとめて、記事としてアップすることにしました。
前回
-
Quiz完了処理
- 正解した場合のみ「学習完了」として記録。
- 学習履歴APIと連携し、学習イベントを保存予定。 -
学習時間の記録
- 開始時間・終了時間をサーバーで記録し、
単語練習+クイズ時間を統合した「総学習時間」を取得。
今回
- 学習時間取得・保存
- 延長タイマー設定
① 学習セッション記録
startSession関数
この関数ではサーバー側の start API を呼び出して新しいセッションを生成し、
サーバーから受け取った durationSec(残り時間=設定時間)を元に 残り時間をクライアント側で計算 しています。
TimerProvider に時間を渡してタイマーを起動し、一定時間が経過したら endSession() を呼び出して終了する流れです。
学習セッションの開始・終了・延長を管理するカスタムフック
import { useTimer } from "@/components/timer/TimerProvider";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
export function useStudySession() {
// 現在のページパス(ページ移動を検知するために使用)
const pathname = usePathname();
const { start, pause, setDurationMin, setEndAtMs } = useTimer();
const [sessionId, setSessionId] = useState<string | null>(null);
// 自動終了タイマーのIDを管理(延長時に再設定するため)
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
//セッション開始処理
const startSession = async () => {
try {
// サーバーへリクエストを送り、新しいセッションを作成
const res = await fetch("/api/study-session/start", { method: "POST" });
if (!res.ok) throw new Error("Failed to start session");
const data = await res.json();
const { sessionId, durationSec } = data;
setSessionId(sessionId);
setDurationMin(durationSec / 60);
start();
// durationSecをもとに自動終了タイマーをセット
const durationMs = durationSec * 1000; // 秒 → ミリ秒
const id = setTimeout(() => endSession(sessionId), durationMs);
setTimeoutId(id);
} catch (err) {
console.error("Error starting session:", err);
}
};
//セッション終了処理
const endSession = async (sessionId: string) => {
if (!sessionId) return;
try {
const res = await fetch("/api/study-session/end", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
});
if (!res.ok) throw new Error("Failed to end session");
} catch (err) {
console.error(err);
}
};
//セッション延長処理
const extendSession = async (minutes = 5) => {
if (!sessionId) return toast.error("セッション情報が見つかりません。");
try {
const res = await fetch(`/api/study-session/${sessionId}/extend`, {
method: "POST",
headers: { "Content-type": "application/json" },
body: JSON.stringify({ addMinutes: minutes }),
});
if (!res.ok) throw new Error("延長失敗");
const data = await res.json();
// サーバーの終了予定時刻(endedAt)を取得してTimerProviderに反映
const nextEndMs = new Date(data.endedAt).getTime();
setEndAtMs(nextEndMs);
// 旧タイマーをキャンセル(延長による上書き防止)
if (timeoutId) clearTimeout(timeoutId);
// 新しい終了タイマーを再セット
const addMs = minutes * 60_000;
const id = setTimeout(() => endSession(sessionId), addMs);
setTimeoutId(id);
toast.success(`${minutes}分延長しました!`);
} catch (error) {
console.error("extendSession error:", error);
toast.error("延長に失敗しました。");
}
};
//ページ移動やアンマウント時の処理
useEffect(() => {
startSession();
return () => {
pause();
if (timeoutId) clearTimeout(timeoutId);
if (sessionId) endSession(sessionId);
};
}, [pathname]);
//タブを閉じた時(beforeunloadイベント)
useEffect(() => {
const handleUnload = () => {
if (sessionId) {
// BlobでJSONを正しく送信
const payload = new Blob(
[JSON.stringify({ sessionId })],
{ type: "application/json" }
);
navigator.sendBeacon("/api/study-session/end", payload);
}
};
window.addEventListener("beforeunload", handleUnload);
return () => window.removeEventListener("beforeunload", handleUnload);
}, [sessionId]);
return { sessionId, startSession, endSession, extendSession };
}
📊 StudySession の記録例

⚠️まだ修正する可能性が高いです。
② 5分延長機能
セッションを5分延長するためのAPIです。
クライアントから addMinutes(5分) を送信し、サーバーでは endedAt を更新します。
api/study-session/[id]/extend/route.ts
import { getDecodedSessionOrRedirect } from "@/lib/authServer";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const user = await getDecodedSessionOrRedirect();
const { addMinutes } = await req.json().catch(() => ({ addMinutes: 5 }));
const addMs = Math.max(1, Number(addMinutes || 0)) * 60_000;
const existing = await prisma.studySession.findFirst({
where: { id: params.id, userId: user.id },
select: { id: true, endedAt: true },
});
if (!existing) return new Response("Not Found", { status: 404 });
const base = existing.endedAt?.getTime() ?? Date.now();
const nextEndedAt = new Date(base + addMs);
const update = await prisma.studySession.update({
where: { id: params.id },
data: { endedAt: nextEndedAt },
select: { endedAt: true },
});
const endedAt = update.endedAt ?? nextEndedAt;
const remainingSec = Math.max(0, Math.ceil((endedAt.getTime() - Date.now()) / 1000));
return new Response(JSON.stringify({ endedAt: endedAt.toISOString(), remainingSec }), { status: 200 });
} catch (error) {
console.error("error : ", error);
return new Response("Internal Server Error", { status: 500 });
}
}
③ Provider 型定義(TimerCtx)
useTimer() で使用するコンテキストの定義。
status, durationMs, remainingMs など、タイマーの状態を一元管理します。
export type Status = "idle" | "running" | "paused";
export type TimerCtx = {
status: Status;
sessionId: string | null;
durationMs: number;
remainingMs: number;
endAtMs: number | null;
setSessionId: (id: string | null) => void;
setDurationMin: (m: number) => void;
start: () => void;
pause: () => void;
resume: () => void;
reset: () => void;
extendBy: (minutes: number) => void;
setEndAtMs: (ms: number) => void;
};
悩んだこと・学んだこと
- useEffectのReturn(アンマウント処理)がうまく動かず、何度も修正していた。
- イベントリスナーを使うことで解決できた :
beforeunloadイベントリスナーを使うことで、
タブを閉じたりページをリロードした時にも確実にendSession()が呼ばれるようになった。
- イベントリスナーを使うことで解決できた :
次回やること
- テストを重ねて一度デプロイ(実際操作しながら、不備やエラーを確認するつもり)
- タイマー初期化の再確認(durationが正しく反映されるように)
- 延長ボタンのUI接続
- ダッシュボードへの反映確認(正確な学習時間の蓄積)
Discussion