mirror of
https://github.com/nihonbuzz/nihonbuzz-academy.git
synced 2026-01-26 13:32:07 +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
|
// Get user progress for this course
|
||||||
$completedLessonsIds = UserProgress::where('user_id', $user->id)
|
$completedLessonsIds = UserProgress::where('user_id', $user->id)
|
||||||
->whereIn('lesson_id', $allLessons->pluck('id'))
|
->whereIn('lesson_id', $allLessons->pluck('id'))
|
||||||
|
->whereNotNull('completed_at')
|
||||||
->pluck('lesson_id')
|
->pluck('lesson_id')
|
||||||
->toArray();
|
->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', [
|
return Inertia::render('Courses/Player', [
|
||||||
'course' => [
|
'course' => [
|
||||||
'id' => $course->id,
|
'id' => $course->id,
|
||||||
@@ -109,4 +116,33 @@ class CoursePlayerController extends Controller
|
|||||||
|
|
||||||
return back()->with('success', 'Materi selesai! +50 XP');
|
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 $guarded = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
'started_at' => 'datetime',
|
||||||
'completed_at' => 'datetime',
|
'completed_at' => 'datetime',
|
||||||
|
'last_heartbeat_at' => 'datetime',
|
||||||
'metadata' => 'array',
|
'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 [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
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 = () => {
|
const handleComplete = () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
router.post(route('lessons.complete', { lesson: currentLesson.id }));
|
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::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::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}/complete', [CoursePlayerController::class, 'complete'])->name('lessons.complete');
|
||||||
|
Route::post('/lessons/{lesson}/heartbeat', [CoursePlayerController::class, 'heartbeat'])->name('lessons.heartbeat');
|
||||||
|
|
||||||
// SRS / Flashcards Routes
|
// SRS / Flashcards Routes
|
||||||
Route::get('/srs', [App\Http\Controllers\SrsController::class, 'index'])->name('srs.index');
|
Route::get('/srs', [App\Http\Controllers\SrsController::class, 'index'])->name('srs.index');
|
||||||
|
|||||||
Reference in New Issue
Block a user