📈

学習日報アプリの「学習時間グラフAPI」と Next.js + Recharts実装メモ

に公開

⚙️ この記事は AI(ChatGPT)による自動生成および編集支援のもとで作成されています。
内容はできる限り正確に記述していますが、参考情報としてご覧ください。
使用ツール:ChatGPT(OpenAI / GPT-5 モデル)


学習日報アプリを作っていると、ほぼ確実にやりたくなるのが グラフ表示 です。

  • 直近 7 日間の学習時間の推移
  • 技術別の学習時間ランキング
  • ジャンル別(バックエンド / フロント / インフラ)の割合

これらを Laravel で API 化して、Next.js + Recharts で描画する ところまでやったので、実装メモをまとめておきます。


ゴール:こんなグラフを出したい

今回の例では、ダッシュボードに以下 2 つのグラフを置きます。

  1. 日別学習時間の折れ線グラフ

    • X 軸: 日付(直近 7〜30 日)
    • Y 軸: 総学習時間(分)
  2. 技術別学習時間の棒グラフ

    • X 軸: 技術名(PHP / Laravel / React / MySQL …)
    • Y 軸: 総学習時間(分)
    • 直近 N 日(例: 30 日分)の集計

API デザイン

エンドポイントはシンプルに /dashboard/stats のようなイメージにしました。

レスポンス例

{
  "status": "success",
  "data": {
    "daily": [
      { "date": "2025-11-24", "total_minutes": 120 },
      { "date": "2025-11-25", "total_minutes": 90 },
      { "date": "2025-11-26", "total_minutes": 150 }
    ],
    "byTechnology": [
      { "technology_id": 1, "technology_name": "PHP", "total_minutes": 300 },
      { "technology_id": 2, "technology_name": "Laravel", "total_minutes": 240 },
      { "technology_id": 3, "technology_name": "React", "total_minutes": 180 }
    ]
  }
}

フロントで扱いやすいように、

  • daily[] … 日別集計
  • byTechnology[] … 技術別集計

という 2 つの配列に分けています。


Laravel 側:集計クエリ

ベースとなるテーブル構成は前回の記事の通りですが、使うのは主にこの 2 つです。

  • study_reports … 日報(1 日 1 レコード)
  • study_report_technologies … 日報 × 技術の中間テーブル

1. 日別学習時間

study_reports から単純に集計するパターンです。

use Illuminate\Support\Facades\DB;

public function getDailyStats(int $memberId, int $days = 7): array
{
    $from = now()->subDays($days - 1)->startOfDay();
    $to   = now()->endOfDay();

    return DB::table('study_reports')
        ->selectRaw('studied_on as date, SUM(total_minutes) as total_minutes')
        ->where('member_id', $memberId)
        ->whereBetween('studied_on', [$from->toDateString(), $to->toDateString()])
        ->whereNull('deleted_at')
        ->groupBy('studied_on')
        ->orderBy('studied_on')
        ->get()
        ->map(fn ($row) => [
            'date' => $row->date,
            'total_minutes' => (int) $row->total_minutes,
        ])
        ->all();
}

ここでは

  • 直近 N 日の範囲を whereBetween で絞る
  • groupBy studied_on で日別に SUM する

というシンプルな構成です。

2. 技術別学習時間

こちらは中間テーブルから集計します。

public function getTechnologyStats(int $memberId, int $days = 30): array
{
    $from = now()->subDays($days - 1)->startOfDay();
    $to   = now()->endOfDay();

    return DB::table('study_report_technologies as srt')
        ->join('study_reports as sr', 'sr.id', '=', 'srt.study_report_id')
        ->join('technologies as t', 't.id', '=', 'srt.technology_id')
        ->selectRaw('
            t.id as technology_id,
            t.display_name as technology_name,
            SUM(srt.minutes) as total_minutes
        ')
        ->where('sr.member_id', $memberId)
        ->whereBetween('sr.studied_on', [$from->toDateString(), $to->toDateString()])
        ->whereNull('sr.deleted_at')
        ->groupBy('t.id', 't.display_name')
        ->orderByDesc('total_minutes')
        ->get()
        ->map(fn ($row) => [
            'technology_id' => (int) $row->technology_id,
            'technology_name' => $row->technology_name,
            'total_minutes' => (int) $row->total_minutes,
        ])
        ->all();
}

ポイントは、

  • study_reports を join して、メンバーと日付範囲で絞る
  • グラフ用なので、結果はそのまま JSON に流せる形へ整形しておく

あたりです。


レスポンスを整える Resource クラス

API のレスポンスを統一するために、Resource 風のクラスを挟んでいます。

class DashboardStatsResource
{
    public static function make(array $daily, array $byTechnology): array
    {
        return [
            'daily' => $daily,
            'byTechnology' => $byTechnology,
        ];
    }
}

コントローラではこんな感じ。

public function stats(Request $request)
{
    $member = $request->user(); // Sanctum

    $daily = $this->dashboardService->getDailyStats($member->id, 7);
    $byTechnology = $this->dashboardService->getTechnologyStats($member->id, 30);

    return response()->json([
        'status' => 'success',
        'data' => DashboardStatsResource::make($daily, $byTechnology),
    ]);
}

Next.js + Recharts 側の実装

型定義

// types/dashboard.ts
export type DailyStat = {
  date: string; // "2025-11-24"
  total_minutes: number;
};

export type TechnologyStat = {
  technology_id: number;
  technology_name: string;
  total_minutes: number;
};

export type DashboardStatsResponse = {
  status: string;
  data: {
    daily: DailyStat[];
    byTechnology: TechnologyStat[];
  };
};

API 取得

// lib/api/dashboard.ts
import { apiGet } from "@/lib/api/client";
import { DashboardStatsResponse } from "@/types/dashboard";

export async function fetchDashboardStats() {
  const res = await apiGet<DashboardStatsResponse>("/dashboard/stats");
  return res.data.data;
}

コンポーネント(折れ線グラフ)

// components/dashboard/DailyStudyChart.tsx
"use client";

import { useEffect, useState } from "react";
import { LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, ResponsiveContainer } from "recharts";
import { DailyStat } from "@/types/dashboard";
import { fetchDashboardStats } from "@/lib/api/dashboard";

export default function DailyStudyChart() {
  const [data, setData] = useState<DailyStat[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    (async () => {
      try {
        const stats = await fetchDashboardStats();
        setData(stats.daily);
      } finally {
        setLoading(false);
      }
    })();
  }, []);

  if (loading) return <div>読み込み中...</div>;
  if (!data.length) return <div>データがありません</div>;

  return (
    <div className="h-64">
      <ResponsiveContainer width="100%" height="100%">
        <LineChart data={data}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="date" />
          <YAxis />
          <Tooltip />
          <Line type="monotone" dataKey="total_minutes" />
        </LineChart>
      </ResponsiveContainer>
    </div>
  );
}

コンポーネント(技術別棒グラフ)

// components/dashboard/TechnologyStudyChart.tsx
"use client";

import { useEffect, useState } from "react";
import { BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, ResponsiveContainer } from "recharts";
import { TechnologyStat } from "@/types/dashboard";
import { fetchDashboardStats } from "@/lib/api/dashboard";

export default function TechnologyStudyChart() {
  const [data, setData] = useState<TechnologyStat[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    (async () => {
      try {
        const stats = await fetchDashboardStats();
        setData(stats.byTechnology);
      } finally {
        setLoading(false);
      }
    })();
  }, []);

  if (loading) return <div>読み込み中...</div>;
  if (!data.length) return <div>データがありません</div>;

  return (
    <div className="h-64">
      <ResponsiveContainer width="100%" height="100%">
        <BarChart data={data}>
          <CartesianGrid strokeDasharray="3 3" />
          <XAxis dataKey="technology_name" />
          <YAxis />
          <Tooltip />
          <Bar dataKey="total_minutes" />
        </BarChart>
      </ResponsiveContainer>
    </div>
  );
}

実装してみての学び

  • API の形を先に決めると、フロントがかなり楽
    「この JSON なら Recharts にそのまま突っ込める」という形を目指す
  • SQL は必要最低限に留めて、細かいラベリングなどは PHP / TS 側でやる
  • date のフォーマットやタイムゾーンは、どこで統一するか最初に決めておく
    (今回は API 側で Y-m-d に揃える方針)

グラフは見た目以上に「集計ロジック」と「データ構造の設計」が大事だなと実感しました。

前回の記事で紹介した DB 設計とセットで読むと、なんとなく「学習日報アプリの全体像」が見えてくると思います 📊

Discussion