From 6caa0e88dded8521565024f6a8dafd0363445edc Mon Sep 17 00:00:00 2001 From: nihonbuzz Date: Fri, 23 Jan 2026 19:06:32 +0700 Subject: [PATCH] feat: implement Learning Tracer (heartbeat duration tracking) for LPK Audit --- .../Controllers/CoursePlayerController.php | 36 +++++++++++++++++++ app/Models/UserProgress.php | 2 ++ ...racking_columns_to_user_progress_table.php | 30 ++++++++++++++++ resources/js/Pages/Courses/Player.tsx | 16 +++++++++ routes/web.php | 1 + 5 files changed, 85 insertions(+) create mode 100644 database/migrations/2026_01_23_115238_add_tracking_columns_to_user_progress_table.php diff --git a/app/Http/Controllers/CoursePlayerController.php b/app/Http/Controllers/CoursePlayerController.php index 92dd2a8..6a71490 100644 --- a/app/Http/Controllers/CoursePlayerController.php +++ b/app/Http/Controllers/CoursePlayerController.php @@ -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, + ]); + } } diff --git a/app/Models/UserProgress.php b/app/Models/UserProgress.php index f0b181e..fd16016 100644 --- a/app/Models/UserProgress.php +++ b/app/Models/UserProgress.php @@ -15,7 +15,9 @@ class UserProgress extends Model protected $guarded = []; protected $casts = [ + 'started_at' => 'datetime', 'completed_at' => 'datetime', + 'last_heartbeat_at' => 'datetime', 'metadata' => 'array', ]; diff --git a/database/migrations/2026_01_23_115238_add_tracking_columns_to_user_progress_table.php b/database/migrations/2026_01_23_115238_add_tracking_columns_to_user_progress_table.php new file mode 100644 index 0000000..8f75f3d --- /dev/null +++ b/database/migrations/2026_01_23_115238_add_tracking_columns_to_user_progress_table.php @@ -0,0 +1,30 @@ +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']); + }); + } +}; diff --git a/resources/js/Pages/Courses/Player.tsx b/resources/js/Pages/Courses/Player.tsx index d207249..4569dd4 100644 --- a/resources/js/Pages/Courses/Player.tsx +++ b/resources/js/Pages/Courses/Player.tsx @@ -148,6 +148,22 @@ export default function Player({ course, currentLesson, progress, auth }: Player const [sidebarOpen, setSidebarOpen] = useState(true); const [isPlaying, setIsPlaying] = useState(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 })); diff --git a/routes/web.php b/routes/web.php index 6236c08..0cc91fb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');