Apa yang Akan Kita Bangun
Fitur real-time membuat platform e-learning kita terasa hidup:
- Monitoring Ujian Langsung - Guru melihat siapa yang sedang mengerjakan ujian secara real-time
- Tracking Progress - Pantau siswa menjawab soal secara langsung
- Hasil Instan - Skor muncul langsung setelah submit
- Update Dashboard - Statistik refresh otomatis
Mengapa Livewire, Bukan Pusher?
| Pendekatan | Kelebihan | Kekurangan |
|---|---|---|
| Livewire Polling | Simpel, tidak perlu setup tambahan | Beban server lebih tinggi |
| Laravel Reverb | WebSocket asli, efisien | Butuh setup server |
| Pusher | Setup mudah | Biaya mahal saat scale |
Untuk kasus kita (lingkungan sekolah, traffic sedang), Livewire polling sudah cukup dan menjaga simplisitas.
Step 1: Widget Monitor Ujian Langsung
Buat widget Filament untuk guru memantau ujian aktif:
php artisan make:filament-widget LiveExamMonitor --panel=teacher
<?php
namespace App\Filament\Teacher\Widgets;
use App\Models\Exam;
use App\Models\ExamAttempt;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
class LiveExamMonitor extends BaseWidget
{
protected int | string | array $columnSpan = 'full';
protected static ?int $sort = 1;
// Refresh setiap 5 detik
protected static string $pollingInterval = '5s';
public function table(Table $table): Table
{
return $table
->query(
ExamAttempt::query()
->where('status', 'in_progress')
->with(['student', 'exam'])
)
->columns([
Tables\Columns\TextColumn::make('student.name')
->label('Siswa')
->searchable(),
Tables\Columns\TextColumn::make('exam.title')
->label('Ujian')
->limit(30),
Tables\Columns\TextColumn::make('started_at')
->since()
->label('Mulai'),
Tables\Columns\TextColumn::make('progress')
->label('Progress')
->getStateUsing(function (ExamAttempt $record): string {
$answered = $record->answers()->count();
$total = $record->exam->questions()->count();
return "{$answered}/{$total}";
}),
Tables\Columns\TextColumn::make('time_remaining')
->label('Sisa Waktu')
->getStateUsing(function (ExamAttempt $record): string {
$elapsed = now()->diffInMinutes($record->started_at);
$remaining = max(0, $record->exam->duration_minutes - $elapsed);
return "{$remaining} menit";
})
->color(fn (string $state): string =>
intval($state) < 10 ? 'danger' : 'success'),
])
->emptyStateHeading('Tidak ada ujian aktif')
->emptyStateDescription('Siswa akan muncul di sini ketika mereka memulai ujian');
}
}
Step 2: Komponen Progress Siswa
Buat komponen Livewire untuk monitoring detail siswa:
php artisan make:livewire ExamProgressTracker
<?php
namespace App\Livewire;
use App\Models\Exam;
use App\Models\ExamAttempt;
use Livewire\Component;
class ExamProgressTracker extends Component
{
public Exam $exam;
public $attempts = [];
public function mount(Exam $exam): void
{
$this->exam = $exam;
}
public function getAttempts()
{
return ExamAttempt::where('exam_id', $this->exam->id)
->with(['student', 'answers'])
->orderByDesc('started_at')
->get()
->map(function ($attempt) {
return [
'id' => $attempt->id,
'student_name' => $attempt->student->name,
'status' => $attempt->status,
'answered_count' => $attempt->answers->count(),
'total_questions' => $this->exam->questions->count(),
'progress_percent' => $this->exam->questions->count() > 0
? round(($attempt->answers->count() / $this->exam->questions->count()) * 100)
: 0,
'score' => $attempt->score,
'started_at' => $attempt->started_at->diffForHumans(),
];
});
}
public function render()
{
return view('livewire.exam-progress-tracker', [
'attempts' => $this->getAttempts(),
]);
}
}
View resources/views/livewire/exam-progress-tracker.blade.php:
<div wire:poll.3s>
<div class="space-y-4">
@forelse($attempts as $attempt)
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
<div class="flex items-center justify-between mb-2">
<span class="font-semibold">{{ $attempt['student_name'] }}</span>
<span @class([
'px-2 py-1 rounded text-sm',
'bg-yellow-100 text-yellow-800' => $attempt['status'] === 'in_progress',
'bg-green-100 text-green-800' => $attempt['status'] === 'completed',
'bg-red-100 text-red-800' => $attempt['status'] === 'timed_out',
])>
{{ ucfirst(str_replace('_', ' ', $attempt['status'])) }}
</span>
</div>
{{-- Progress Bar --}}
<div class="w-full bg-gray-200 rounded-full h-2.5 mb-2">
<div
class="bg-blue-600 h-2.5 rounded-full transition-all duration-500"
style="width: {{ $attempt['progress_percent'] }}%"
></div>
</div>
<div class="flex justify-between text-sm text-gray-500">
<span>{{ $attempt['answered_count'] }}/{{ $attempt['total_questions'] }} soal</span>
@if($attempt['status'] === 'completed')
<span class="font-bold text-lg {{ $attempt['score'] >= 60 ? 'text-green-600' : 'text-red-600' }}">
Skor: {{ $attempt['score'] }}%
</span>
@else
<span>Dimulai {{ $attempt['started_at'] }}</span>
@endif
</div>
</div>
@empty
<div class="text-center text-gray-500 py-8">
Belum ada siswa yang mengerjakan ujian ini
</div>
@endforelse
</div>
</div>
Step 3: Widget Statistik Dashboard
Buat widget dashboard real-time:
<?php
namespace App\Filament\Teacher\Widgets;
use App\Models\Exam;
use App\Models\ExamAttempt;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class ExamStatsWidget extends BaseWidget
{
protected static ?string $pollingInterval = '10s';
protected function getStats(): array
{
$activeExams = Exam::where('status', 'published')
->where('start_time', '<=', now())
->where('end_time', '>=', now())
->count();
$studentsInProgress = ExamAttempt::where('status', 'in_progress')->count();
$todayCompleted = ExamAttempt::where('status', 'completed')
->whereDate('completed_at', today())
->count();
$averageScore = ExamAttempt::where('status', 'completed')
->whereDate('completed_at', today())
->avg('score') ?? 0;
return [
Stat::make('Ujian Aktif', $activeExams)
->description('Sedang berjalan')
->descriptionIcon('heroicon-m-clipboard-document-check')
->color('success'),
Stat::make('Siswa Sedang Mengerjakan', $studentsInProgress)
->description('Mengerjakan ujian sekarang')
->descriptionIcon('heroicon-m-user-group')
->color('warning'),
Stat::make('Selesai Hari Ini', $todayCompleted)
->description('Ujian selesai')
->descriptionIcon('heroicon-m-check-circle')
->color('info'),
Stat::make('Skor Rata-rata', round($averageScore) . '%')
->description('Rata-rata hari ini')
->descriptionIcon('heroicon-m-chart-bar')
->color($averageScore >= 60 ? 'success' : 'danger'),
];
}
}
Step 4: Auto-Save dengan Debouncing
Update halaman pengerjaan ujian untuk auto-save jawaban secara efisien:
// Di komponen TakeExam
public function saveAnswer(int $questionId, string $answer): void
{
// Debounce ditangani oleh wire:model.debounce Livewire
Answer::updateOrCreate(
[
'exam_attempt_id' => $this->attempt->id,
'question_id' => $questionId,
],
[
'answer' => $answer,
]
);
$this->answers[$questionId] = $answer;
// Update jumlah jawaban di attempt
$this->attempt->update([
'total_answered' => count(array_filter($this->answers)),
]);
}
Di template Blade:
{{-- Untuk soal essay dengan debounce --}}
<textarea
wire:model.debounce.500ms="answers.{{ $question->id }}"
wire:change="saveAnswer({{ $question->id }}, $event.target.value)"
class="w-full border rounded-lg p-3"
rows="4"
></textarea>
Step 5: Notifikasi saat Ujian Dikumpulkan
use Filament\Notifications\Notification;
use Filament\Notifications\Actions\Action;
// Ketika ujian dikumpulkan
public function submitExam(): void
{
// ... logika penilaian ...
// Kirim notifikasi ke guru
$teacher = $this->exam->subject->teacher;
Notification::make()
->title('Siswa Menyelesaikan Ujian')
->body("{$this->attempt->student->name} mendapat skor {$score}% di {$this->exam->title}")
->icon('heroicon-o-academic-cap')
->iconColor($score >= $this->exam->passing_score ? 'success' : 'danger')
->actions([
Action::make('view')
->button()
->url(route('filament.teacher.pages.exam-progress', ['exam' => $this->exam])),
])
->sendToDatabase($teacher);
}
Step 6: Aktifkan Database Notifications
php artisan notifications:table
php artisan migrate
Update model User:
use Illuminate\Notifications\Notifiable;
use Filament\Models\Contracts\HasNotifications;
use Filament\Notifications\HasDatabaseNotifications;
class User extends Authenticatable implements FilamentUser, HasNotifications
{
use HasDatabaseNotifications;
// ...
}
Aktifkan di panel provider:
->databaseNotifications()
->databaseNotificationsPolling('30s')
Ringkasan
Kita telah menambahkan fitur real-time:
- ā Widget monitoring ujian langsung
- ā Tracking progress real-time
- ā Statistik dashboard auto-update
- ā Auto-save dengan debouncing
- ā Database notifications untuk guru
Selanjutnya
Di Part 7, kita akan menambahkan testing komprehensif:
- Feature tests untuk alur ujian
- Unit tests untuk logika penilaian
- Browser tests dengan Laravel Dusk
Lanjut ke Part 7: Strategi Testing ā
