-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Study Tracker with stopwatch, syllabus checklist, and profile progress #347
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a0f7df9
e2046b1
55a2d43
81d1a90
4aa5a01
f3628d2
6395d6d
faf8f8b
80d4fc7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| <?php | ||
|
|
||
| namespace App\Http\Controllers; | ||
|
|
||
| use App\Models\DailyStudyLog; | ||
| use App\Models\Node; | ||
| use App\Models\NodeCompletion; | ||
| use App\Models\Subject; | ||
| use Carbon\Carbon; | ||
| use Illuminate\Http\Request; | ||
| use Inertia\Inertia; | ||
| use Inertia\Response; | ||
|
|
||
| class StudyTrackerController extends Controller | ||
| { | ||
| public function index(Request $request): Response | ||
| { | ||
| $user = $request->user(); | ||
| $course = $user?->curriculum ?: 'hsc'; | ||
|
|
||
| $subjects = Subject::where('course', $course) | ||
| ->where('is_trackable', true) | ||
| ->orderBy('sort_order', 'asc') | ||
| ->with(['nodes' => function ($query) { | ||
| $query->where('is_trackable', true) | ||
| ->orderBy('sort_order', 'asc') | ||
| ->select('id', 'subject_id', 'name', 'slug', 'sort_order'); | ||
| }]) | ||
| ->get(['id', 'name', 'english_name', 'slug', 'course', 'sort_order']) | ||
| ->toArray(); | ||
|
|
||
| if (! $user) { | ||
| return Inertia::render('Tracker/Index', [ | ||
| 'course' => $course, | ||
| 'subjects' => $subjects, | ||
| 'completedNodeIds' => [], | ||
| 'todaySeconds' => 0, | ||
| 'stats' => [ | ||
| 'currentStreak' => 0, | ||
| 'longestStreak' => 0, | ||
| 'totalSeconds' => 0, | ||
| 'totalDays' => 0, | ||
| ], | ||
| 'heatmapData' => [], | ||
| ]); | ||
| } | ||
|
|
||
| $completedNodeIds = NodeCompletion::where('user_id', $user->id) | ||
| ->pluck('node_id') | ||
| ->toArray(); | ||
|
|
||
| $todayStr = Carbon::today()->format('Y-m-d'); | ||
| $todayLog = DailyStudyLog::where('user_id', $user->id) | ||
| ->where('study_date', $todayStr) | ||
| ->first(); | ||
| $todaySeconds = (int) ($todayLog?->total_seconds ?? 0); | ||
|
|
||
| $studyData = DailyStudyLog::getHeatmapAndStatsForUser($user); | ||
|
|
||
| return Inertia::render('Tracker/Index', [ | ||
| 'course' => $course, | ||
| 'subjects' => $subjects, | ||
| 'completedNodeIds' => $completedNodeIds, | ||
| 'todaySeconds' => $todaySeconds, | ||
| 'stats' => $studyData['stats'], | ||
| 'heatmapData' => $studyData['heatmapData'], | ||
| ]); | ||
| } | ||
|
|
||
| public function toggleNode(Request $request, Node $node) | ||
| { | ||
| $user = $request->user(); | ||
| if (! $user) { | ||
| return back()->with('error', 'Authentication required'); | ||
| } | ||
|
|
||
| $existing = NodeCompletion::where('user_id', $user->id) | ||
| ->where('node_id', $node->id) | ||
| ->first(); | ||
|
|
||
| if ($existing) { | ||
| $existing->delete(); | ||
| } else { | ||
| NodeCompletion::create([ | ||
| 'user_id' => $user->id, | ||
| 'node_id' => $node->id, | ||
| ]); | ||
| } | ||
|
|
||
| return back(); | ||
| } | ||
|
|
||
| public function logTime(Request $request) | ||
| { | ||
| $user = $request->user(); | ||
| if (! $user) { | ||
| return back()->with('error', 'Authentication required'); | ||
| } | ||
|
|
||
| $validated = $request->validate([ | ||
| 'seconds' => 'required|integer|min:1|max:86400', | ||
| 'date' => 'nullable|date_format:Y-m-d|before_or_equal:today', | ||
| ]); | ||
|
|
||
| $studyDate = $validated['date'] ?? Carbon::today()->format('Y-m-d'); | ||
|
|
||
| $log = DailyStudyLog::firstOrCreate( | ||
| [ | ||
| 'user_id' => $user->id, | ||
| 'study_date' => $studyDate, | ||
| ], | ||
| [ | ||
| 'total_seconds' => 0, | ||
| ] | ||
| ); | ||
|
|
||
| $maxAllowed = max(0, 86400 - (int) $log->total_seconds); | ||
| if ($maxAllowed > 0) { | ||
| $secondsToAdd = min((int) $validated['seconds'], $maxAllowed); | ||
| $log->increment('total_seconds', $secondsToAdd); | ||
|
Comment on lines
+117
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Make the daily limit update atomic. Concurrent requests can read the same Serialize updates for the user and study date with a transaction and row lock, or use one conditional database update that clamps the stored value. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| return back(); | ||
| } | ||
|
|
||
| public function resetToday(Request $request) | ||
| { | ||
| $user = $request->user(); | ||
| if (! $user) { | ||
| return back()->with('error', 'Authentication required'); | ||
| } | ||
|
|
||
| $todayStr = Carbon::today()->format('Y-m-d'); | ||
| DailyStudyLog::where('user_id', $user->id) | ||
| ->where('study_date', $todayStr) | ||
| ->update(['total_seconds' => 0]); | ||
|
|
||
| return back(); | ||
| } | ||
|
|
||
| public function updateCurriculum(Request $request) | ||
| { | ||
| $user = $request->user(); | ||
| if (! $user) { | ||
| return back()->with('error', 'Authentication required'); | ||
| } | ||
|
|
||
| $validated = $request->validate([ | ||
| 'curriculum' => 'required|string|in:hsc,ssc', | ||
| ]); | ||
|
|
||
| $newCurriculum = $validated['curriculum']; | ||
|
|
||
| if ($user->curriculum !== $newCurriculum) { | ||
| $user->update([ | ||
| 'curriculum' => $newCurriculum, | ||
| ]); | ||
|
|
||
| // Clear the entire chapter completion track for the user | ||
| NodeCompletion::where('user_id', $user->id)->delete(); | ||
| } | ||
|
|
||
| if ($request->wantsJson()) { | ||
| return response()->json([ | ||
| 'success' => true, | ||
| 'curriculum' => $newCurriculum, | ||
| ]); | ||
| } | ||
|
|
||
| return redirect()->route('tracker.index') | ||
| ->with('success', 'Curriculum updated to '.strtoupper($newCurriculum).' and chapter track reset.'); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Reject nodes outside the active tracker scope.
toggleNodeaccepts any route-bound node. An authenticated user can submit an untrackable node or a node from another curriculum. The endpoint then stores hidden completion data that violates the tracker contract.Require
node.is_trackable, a trackable subject, and a subject course that matches the user's curriculum before creating the completion.Also applies to: 86-89
🧰 Tools
🪛 PHPStan (2.2.12)
[error] 79-79: Call to an undefined static method App\Models\NodeCompletion::where().
(staticMethod.notFound)
🤖 Prompt for AI Agents