📈
学習日報アプリの「学習時間グラフAPI」と Next.js + Recharts実装メモ
⚙️ この記事は AI(ChatGPT)による自動生成および編集支援のもとで作成されています。
内容はできる限り正確に記述していますが、参考情報としてご覧ください。
使用ツール:ChatGPT(OpenAI / GPT-5 モデル)
学習日報アプリを作っていると、ほぼ確実にやりたくなるのが グラフ表示 です。
- 直近 7 日間の学習時間の推移
- 技術別の学習時間ランキング
- ジャンル別(バックエンド / フロント / インフラ)の割合
これらを Laravel で API 化して、Next.js + Recharts で描画する ところまでやったので、実装メモをまとめておきます。
ゴール:こんなグラフを出したい
今回の例では、ダッシュボードに以下 2 つのグラフを置きます。
-
日別学習時間の折れ線グラフ
- X 軸: 日付(直近 7〜30 日)
- Y 軸: 総学習時間(分)
-
技術別学習時間の棒グラフ
- 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