🙌

【開発ログ】学習時間を正しく取得・保存しよう

に公開

実はこの記事を書いている途中でエラーが発生し、しばらく更新が止まっていました。😅
まだ完全には解決できていませんが、重複レンダリング や 状態の初期化ずれ などが原因でした。また、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 の記録例
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