feat: implement Learning Tracer (heartbeat duration tracking) for LPK Audit

This commit is contained in:
2026-01-23 19:06:32 +07:00
parent 8a61cda3e4
commit 6caa0e88dd
5 changed files with 85 additions and 0 deletions

View File

@@ -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,
]);
}
}

View File

@@ -15,7 +15,9 @@ class UserProgress extends Model
protected $guarded = [];
protected $casts = [
'started_at' => 'datetime',
'completed_at' => 'datetime',
'last_heartbeat_at' => 'datetime',
'metadata' => 'array',
];

View File

@@ -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']);
});
}
};

View File

@@ -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 }));

View File

@@ -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');