mirror of
https://github.com/nihonbuzz/nihonbuzz-academy.git
synced 2026-01-27 02:41:58 +07:00
feat: implement Learning Tracer (heartbeat duration tracking) for LPK Audit
This commit is contained in:
@@ -45,9 +45,16 @@ class CoursePlayerController extends Controller
|
||||
// Get user progress for this course
|
||||
$completedLessonsIds = UserProgress::where('user_id', $user->id)
|
||||
->whereIn('lesson_id', $allLessons->pluck('id'))
|
||||
->whereNotNull('completed_at')
|
||||
->pluck('lesson_id')
|
||||
->toArray();
|
||||
|
||||
// Initialize started_at if first time viewing
|
||||
UserProgress::firstOrCreate(
|
||||
['user_id' => $user->id, 'lesson_id' => $currentLesson->id],
|
||||
['started_at' => now(), 'last_heartbeat_at' => now()]
|
||||
);
|
||||
|
||||
return Inertia::render('Courses/Player', [
|
||||
'course' => [
|
||||
'id' => $course->id,
|
||||
@@ -109,4 +116,33 @@ class CoursePlayerController extends Controller
|
||||
|
||||
return back()->with('success', 'Materi selesai! +50 XP');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update learning duration via heartbeat.
|
||||
*/
|
||||
public function heartbeat(Request $request, Lesson $lesson)
|
||||
{
|
||||
$user = $request->user();
|
||||
$now = now();
|
||||
|
||||
$progress = UserProgress::firstOrCreate(
|
||||
['user_id' => $user->id, 'lesson_id' => $lesson->id],
|
||||
['started_at' => $now, 'last_heartbeat_at' => $now]
|
||||
);
|
||||
|
||||
$lastHeartbeat = $progress->last_heartbeat_at ?? $progress->created_at;
|
||||
$diffSeconds = $now->diffInSeconds($lastHeartbeat);
|
||||
|
||||
// Limit diff to prevent massive jumps (e.g. if user leaves tab open and comes back hours later)
|
||||
// Max 90 seconds per heartbeat (assuming heartbeat is every 60s)
|
||||
$increment = min($diffSeconds, 90);
|
||||
|
||||
$progress->increment('time_spent_seconds', $increment);
|
||||
$progress->update(['last_heartbeat_at' => $now]);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'time_spent' => $progress->time_spent_seconds,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ class UserProgress extends Model
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_progress', function (Blueprint $table) {
|
||||
$table->timestamp('started_at')->nullable()->after('lesson_id');
|
||||
$table->unsignedInteger('time_spent_seconds')->default(0)->after('completed_at');
|
||||
$table->timestamp('last_heartbeat_at')->nullable()->after('time_spent_seconds');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_progress', function (Blueprint $table) {
|
||||
$table->dropColumn(['started_at', 'time_spent_seconds', 'last_heartbeat_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -148,6 +148,22 @@ export default function Player({ course, currentLesson, progress, auth }: Player
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
|
||||
// Learning Tracer - Heartbeat every 60 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
// @ts-ignore
|
||||
router.post(route('lessons.heartbeat', { lesson: currentLesson.id }), {}, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
only: [], // Prevent any data reload to keep it "silent"
|
||||
// @ts-ignore
|
||||
onFinish: () => {}
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentLesson.id]);
|
||||
|
||||
const handleComplete = () => {
|
||||
// @ts-ignore
|
||||
router.post(route('lessons.complete', { lesson: currentLesson.id }));
|
||||
|
||||
@@ -32,6 +32,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::post('/courses/{course:slug}/enroll', [App\Http\Controllers\CourseLibraryController::class, 'enroll'])->name('courses.enroll');
|
||||
Route::get('/courses/{course:slug}/learn/{lesson:slug?}', [CoursePlayerController::class, 'show'])->name('courses.learn');
|
||||
Route::post('/lessons/{lesson}/complete', [CoursePlayerController::class, 'complete'])->name('lessons.complete');
|
||||
Route::post('/lessons/{lesson}/heartbeat', [CoursePlayerController::class, 'heartbeat'])->name('lessons.heartbeat');
|
||||
|
||||
// SRS / Flashcards Routes
|
||||
Route::get('/srs', [App\Http\Controllers\SrsController::class, 'index'])->name('srs.index');
|
||||
|
||||
Reference in New Issue
Block a user