diff --git a/app/Http/Controllers/Admin/NodeController.php b/app/Http/Controllers/Admin/NodeController.php index 67bea154..e5304aca 100644 --- a/app/Http/Controllers/Admin/NodeController.php +++ b/app/Http/Controllers/Admin/NodeController.php @@ -88,6 +88,7 @@ public function store(StoreNodeRequest $request, Subject $subject) 'name' => $validated['name'], 'slug' => $slug, 'sort_order' => $validated['sort_order'] ?? 0, + 'is_trackable' => (bool) ($validated['is_trackable'] ?? false), ]); return back()->with('success', 'Folder created successfully.'); @@ -140,6 +141,10 @@ public function update(UpdateNodeRequest $request, Subject $subject, Node $node) $node->sort_order = $validated['sort_order'] ?? 0; } + if (array_key_exists('is_trackable', $validated)) { + $node->is_trackable = (bool) $validated['is_trackable']; + } + $node->save(); return back()->with('success', 'Folder updated successfully.'); diff --git a/app/Http/Controllers/StudyTrackerController.php b/app/Http/Controllers/StudyTrackerController.php new file mode 100644 index 00000000..677d6128 --- /dev/null +++ b/app/Http/Controllers/StudyTrackerController.php @@ -0,0 +1,173 @@ +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); + } + + 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.'); + } +} diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index d62ed314..f1f500fa 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -6,10 +6,13 @@ use App\Models\AppSetting; use App\Models\BlogComment; use App\Models\BlogReaction; +use App\Models\DailyStudyLog; use App\Models\ForumAnswer; use App\Models\ForumPost; use App\Models\Node; +use App\Models\NodeCompletion; use App\Models\Resource; +use App\Models\Subject; use App\Models\User; use App\Models\UserAppreciation; use App\Notifications\StudyPokeNotification; @@ -31,6 +34,7 @@ public function show(string $username) 'username' => $user->username, 'about' => $user->about, 'institution' => $user->institution, + 'curriculum' => $user->curriculum ?? 'hsc', 'image_url' => $user->image_url, 'facebook' => $user->facebook, 'instagram' => $user->instagram, @@ -121,10 +125,6 @@ public function show(string $username) ]); } - $questionsCount = ForumPost::where('user_id', $user->id)->approved()->count(); - $answersCount = ForumAnswer::where('user_id', $user->id) - ->whereHas('post', fn ($q) => $q->approved()) - ->count(); $forumPosts = ForumPost::where('user_id', $user->id) ->approved() ->with(['subject:id,name,course,slug', 'node:id,name,slug']) @@ -144,9 +144,6 @@ public function show(string $username) ->latest() ->take(5) ->get(); - $blogsCount = $user->blogs()->where('is_published', true)->count(); - $totalBlogViews = (int) $user->blogs()->where('is_published', true)->sum('views'); - $sharedResourcesCount = Resource::where('user_id', $user->id)->count(); // Recent Community Activities $recentForumPosts = ForumPost::where('user_id', $user->id) @@ -263,15 +260,10 @@ public function show(string $username) ->filter(fn ($item) => $item['title'] !== null) ->values(); + $syllabusProgress = $this->getSyllabusProgress($user); + return Inertia::render('User/Show', [ 'profileUser' => $profileUser, - 'stats' => [ - 'questionsCount' => $questionsCount, - 'answersCount' => $answersCount, - 'blogsCount' => $blogsCount, - 'sharedResourcesCount' => $sharedResourcesCount, - 'totalBlogViews' => (int) $totalBlogViews, - ], 'appreciationsCount' => $appreciationsCount, 'appreciatingCount' => $appreciatingCount, 'isAppreciated' => $isAppreciated, @@ -294,6 +286,8 @@ public function show(string $username) ], 'suggestedUsers' => $suggestedUsers, 'pokeData' => $pokeData, + 'studyHeatmap' => DailyStudyLog::getHeatmapAndStatsForUser($user), + 'syllabusProgress' => $syllabusProgress, ]); } @@ -391,4 +385,66 @@ private function buildNodeUrl(Node $node): ?string return '/'.$node->subject->slug.'/'.implode('/', $slugs); } + + private function getSyllabusProgress(User $user): array + { + $course = $user->curriculum ?: 'hsc'; + $trackableSubjects = 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', 'tailwind_format', 'icon', 'sort_order']); + + $completedNodeIds = NodeCompletion::where('user_id', $user->id) + ->pluck('node_id') + ->toArray(); + + $completedSet = array_flip($completedNodeIds); + + $subjectBreakdown = []; + $totalChapters = 0; + $completedChapters = 0; + + foreach ($trackableSubjects as $subj) { + $subjTotal = $subj->nodes->count(); + $subjCompleted = 0; + foreach ($subj->nodes as $node) { + if (isset($completedSet[$node->id])) { + $subjCompleted++; + } + } + + $totalChapters += $subjTotal; + $completedChapters += $subjCompleted; + + $subjPercent = $subjTotal > 0 ? (int) round(($subjCompleted / $subjTotal) * 100) : 0; + + $subjectBreakdown[] = [ + 'id' => $subj->id, + 'name' => $subj->name, + 'english_name' => $subj->english_name, + 'slug' => $subj->slug, + 'course' => $subj->course, + 'tailwind_format' => $subj->tailwind_format, + 'icon' => $subj->icon, + 'completed' => $subjCompleted, + 'total' => $subjTotal, + 'percent' => $subjPercent, + ]; + } + + $overallPercent = $totalChapters > 0 ? (int) round(($completedChapters / $totalChapters) * 100) : 0; + + return [ + 'course' => $course, + 'overallPercent' => $overallPercent, + 'completedChapters' => $completedChapters, + 'totalChapters' => $totalChapters, + 'subjects' => $subjectBreakdown, + ]; + } } diff --git a/app/Http/Requests/Node/StoreNodeRequest.php b/app/Http/Requests/Node/StoreNodeRequest.php index 333a2886..271f03e0 100644 --- a/app/Http/Requests/Node/StoreNodeRequest.php +++ b/app/Http/Requests/Node/StoreNodeRequest.php @@ -27,6 +27,7 @@ public function rules(): array 'slug' => ['nullable', 'string', 'max:200'], 'parent_id' => ['nullable', 'integer'], 'sort_order' => ['sometimes', 'integer'], + 'is_trackable' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Http/Requests/Node/UpdateNodeRequest.php b/app/Http/Requests/Node/UpdateNodeRequest.php index deeadfd4..b550ce8c 100644 --- a/app/Http/Requests/Node/UpdateNodeRequest.php +++ b/app/Http/Requests/Node/UpdateNodeRequest.php @@ -27,6 +27,7 @@ public function rules(): array 'slug' => ['sometimes', 'nullable', 'string', 'max:200'], 'parent_id' => ['sometimes', 'nullable', 'integer'], 'sort_order' => ['sometimes', 'nullable', 'integer'], + 'is_trackable' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Http/Requests/Subject/StoreSubjectRequest.php b/app/Http/Requests/Subject/StoreSubjectRequest.php index 44f3e9b1..45fff49f 100644 --- a/app/Http/Requests/Subject/StoreSubjectRequest.php +++ b/app/Http/Requests/Subject/StoreSubjectRequest.php @@ -42,6 +42,7 @@ public function rules(): array 'icon' => ['required', 'string', 'max:50'], 'sort_order' => ['required', 'integer'], 'course' => ['required', 'string', 'in:ssc,hsc'], + 'is_trackable' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Http/Requests/Subject/UpdateSubjectRequest.php b/app/Http/Requests/Subject/UpdateSubjectRequest.php index eb8b0e5c..308f00c3 100644 --- a/app/Http/Requests/Subject/UpdateSubjectRequest.php +++ b/app/Http/Requests/Subject/UpdateSubjectRequest.php @@ -43,6 +43,7 @@ public function rules(): array 'sort_order' => ['sometimes', 'integer'], 'slug' => ['sometimes', 'string', 'max:100', Rule::unique('subjects', 'slug')->ignore($subject->id)], 'course' => ['sometimes', 'string', 'in:ssc,hsc'], + 'is_trackable' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Models/DailyStudyLog.php b/app/Models/DailyStudyLog.php new file mode 100644 index 00000000..c205b85f --- /dev/null +++ b/app/Models/DailyStudyLog.php @@ -0,0 +1,132 @@ + 'date:Y-m-d', + 'total_seconds' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get heatmap series and streaks stats for a given user. + */ + public static function getHeatmapAndStatsForUser(User $user): array + { + $today = Carbon::today(); + $startDate = (clone $today)->subDays(364); + + $logs = static::where('user_id', $user->id) + ->where('study_date', '>=', $startDate->format('Y-m-d')) + ->get(['study_date', 'total_seconds']) + ->keyBy(function ($log) { + return Carbon::parse($log->study_date)->format('Y-m-d'); + }); + + $period = CarbonPeriod::create($startDate, $today); + $heatmapData = []; + + foreach ($period as $date) { + $dateStr = $date->format('Y-m-d'); + $seconds = (int) ($logs->get($dateStr)?->total_seconds ?? 0); + + $level = 0; + if ($seconds > 0) { + if ($seconds < 7200) { + $level = 1; + } elseif ($seconds < 14400) { + $level = 2; + } elseif ($seconds < 21600) { + $level = 3; + } elseif ($seconds < 28800) { + $level = 4; + } else { + $level = 5; + } + } + + $heatmapData[] = [ + 'date' => $dateStr, + 'seconds' => $seconds, + 'level' => $level, + ]; + } + + $allActiveLogs = static::where('user_id', $user->id) + ->where('total_seconds', '>', 0) + ->orderBy('study_date', 'asc') + ->pluck('study_date') + ->map(fn ($d) => Carbon::parse($d)->format('Y-m-d')) + ->values(); + + $activeDatesSet = array_flip($allActiveLogs->toArray()); + $totalSeconds = (int) static::where('user_id', $user->id)->sum('total_seconds'); + $totalDays = count($activeDatesSet); + + // Calculate current streak + $currentStreak = 0; + $checkDate = clone $today; + + if (! isset($activeDatesSet[$checkDate->format('Y-m-d')])) { + $checkDate->subDay(); + } + + while (isset($activeDatesSet[$checkDate->format('Y-m-d')])) { + $currentStreak++; + $checkDate->subDay(); + } + + // Calculate longest streak + $longestStreak = 0; + $tempStreak = 0; + $prevDate = null; + + foreach ($allActiveLogs as $dateStr) { + $currentCarbon = Carbon::parse($dateStr); + if ($prevDate === null) { + $tempStreak = 1; + } else { + $diff = $prevDate->diffInDays($currentCarbon); + if ($diff === 1) { + $tempStreak++; + } elseif ($diff > 1) { + $tempStreak = 1; + } + } + $prevDate = $currentCarbon; + if ($tempStreak > $longestStreak) { + $longestStreak = $tempStreak; + } + } + + return [ + 'heatmapData' => $heatmapData, + 'stats' => [ + 'currentStreak' => $currentStreak, + 'longestStreak' => $longestStreak, + 'totalSeconds' => $totalSeconds, + 'totalDays' => $totalDays, + ], + ]; + } +} diff --git a/app/Models/Node.php b/app/Models/Node.php index 0c87cb5a..317da0e4 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -16,11 +16,13 @@ class Node extends Model 'name', 'slug', 'sort_order', + 'is_trackable', ]; protected function casts(): array { return [ + 'is_trackable' => 'boolean', 'children_count' => 'integer', 'resources_count' => 'integer', 'upvotes_count' => 'integer', @@ -67,4 +69,9 @@ public function downvotes() { return $this->hasMany(NodeVote::class)->where('type', 'down'); } + + public function completions() + { + return $this->hasMany(NodeCompletion::class); + } } diff --git a/app/Models/NodeCompletion.php b/app/Models/NodeCompletion.php new file mode 100644 index 00000000..18e7689c --- /dev/null +++ b/app/Models/NodeCompletion.php @@ -0,0 +1,24 @@ +belongsTo(Node::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Subject.php b/app/Models/Subject.php index 611e8171..32be626d 100644 --- a/app/Models/Subject.php +++ b/app/Models/Subject.php @@ -8,6 +8,13 @@ class Subject extends Model { protected $guarded = []; + protected function casts(): array + { + return [ + 'is_trackable' => 'boolean', + ]; + } + public function nodes() { return $this->hasMany(Node::class); diff --git a/app/Models/User.php b/app/Models/User.php index 1b0a7fe1..b10f7e74 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -51,6 +51,7 @@ class User extends Authenticatable 'title', 'institution', 'activity_privacy', + 'curriculum', 'allow_pokes', 'facebook', 'instagram', @@ -110,6 +111,16 @@ protected function casts(): array ]; } + public function nodeCompletions(): HasMany + { + return $this->hasMany(NodeCompletion::class); + } + + public function dailyStudyLogs(): HasMany + { + return $this->hasMany(DailyStudyLog::class); + } + public function resources() { return $this->hasMany(Resource::class); diff --git a/database/migrations/2026_09_18_124156_add_is_trackable_to_subjects_and_nodes_tables.php b/database/migrations/2026_09_18_124156_add_is_trackable_to_subjects_and_nodes_tables.php new file mode 100644 index 00000000..1877d5a3 --- /dev/null +++ b/database/migrations/2026_09_18_124156_add_is_trackable_to_subjects_and_nodes_tables.php @@ -0,0 +1,36 @@ +boolean('is_trackable')->default(true)->after('sort_order'); + }); + + Schema::table('nodes', function (Blueprint $table) { + $table->boolean('is_trackable')->default(false)->after('sort_order'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('nodes', function (Blueprint $table) { + $table->dropColumn('is_trackable'); + }); + + Schema::table('subjects', function (Blueprint $table) { + $table->dropColumn('is_trackable'); + }); + } +}; diff --git a/database/migrations/2026_09_18_124804_add_curriculum_to_users_table.php b/database/migrations/2026_09_18_124804_add_curriculum_to_users_table.php new file mode 100644 index 00000000..97a487c1 --- /dev/null +++ b/database/migrations/2026_09_18_124804_add_curriculum_to_users_table.php @@ -0,0 +1,28 @@ +string('curriculum', 10)->default('hsc')->after('institution'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('curriculum'); + }); + } +}; diff --git a/database/migrations/2026_09_18_172000_create_node_completions_table.php b/database/migrations/2026_09_18_172000_create_node_completions_table.php new file mode 100644 index 00000000..95ba67b1 --- /dev/null +++ b/database/migrations/2026_09_18_172000_create_node_completions_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('node_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['node_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('node_completions'); + } +}; diff --git a/database/migrations/2026_09_18_172001_create_daily_study_logs_table.php b/database/migrations/2026_09_18_172001_create_daily_study_logs_table.php new file mode 100644 index 00000000..383eee21 --- /dev/null +++ b/database/migrations/2026_09_18_172001_create_daily_study_logs_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->date('study_date'); + $table->unsignedInteger('total_seconds')->default(0); + $table->timestamps(); + + $table->unique(['user_id', 'study_date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('daily_study_logs'); + } +}; diff --git a/resources/js/components/admin/CreateNodeModal.vue b/resources/js/components/admin/CreateNodeModal.vue index 60826e56..a17cfbec 100644 --- a/resources/js/components/admin/CreateNodeModal.vue +++ b/resources/js/components/admin/CreateNodeModal.vue @@ -25,6 +25,7 @@ const props = defineProps<{ name: string; slug: string; sort_order?: number; + is_trackable?: boolean; } | null; }>(); @@ -35,6 +36,7 @@ const emit = defineEmits<{ const name = ref(''); const slug = ref(''); const sortOrder = ref(0); +const isTrackable = ref(false); const showAdvanced = ref(false); const isSaving = ref(false); const errorMessage = ref(''); @@ -44,10 +46,12 @@ const initForm = () => { name.value = props.node.name || ''; slug.value = props.node.slug || ''; sortOrder.value = props.node.sort_order ?? 0; + isTrackable.value = Boolean(props.node.is_trackable); } else { name.value = ''; slug.value = ''; sortOrder.value = 0; + isTrackable.value = false; } errorMessage.value = ''; @@ -84,6 +88,7 @@ const submitForm = () => { slug: slug.value || null, parent_id: props.parent?.id || null, sort_order: sortOrder.value, + is_trackable: isTrackable.value, }; if (props.node) { @@ -227,6 +232,32 @@ const submitForm = () => { class="w-full rounded-lg border border-slate-300 bg-white px-2.5 py-1.5 text-xs text-slate-900 placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500" /> + + +
+ +
diff --git a/resources/js/components/admin/CreateSubjectModal.vue b/resources/js/components/admin/CreateSubjectModal.vue index 4f1ee522..bce5077b 100644 --- a/resources/js/components/admin/CreateSubjectModal.vue +++ b/resources/js/components/admin/CreateSubjectModal.vue @@ -23,6 +23,7 @@ const props = defineProps<{ tailwind_format: string; icon: string; sort_order: number; + is_trackable?: boolean; } | null; }>(); @@ -56,6 +57,7 @@ const course = ref('hsc'); const tailwindFormat = ref('bg-indigo-50 text-indigo-600'); const icon = ref('BookOpen'); const sortOrder = ref(0); +const isTrackable = ref(false); const initForm = () => { if (props.subject) { @@ -67,6 +69,7 @@ const initForm = () => { props.subject.tailwind_format || 'bg-indigo-50 text-indigo-600'; icon.value = props.subject.icon || 'BookOpen'; sortOrder.value = props.subject.sort_order ?? 0; + isTrackable.value = Boolean(props.subject.is_trackable); } else { name.value = ''; englishName.value = ''; @@ -75,6 +78,7 @@ const initForm = () => { tailwindFormat.value = 'bg-indigo-50 text-indigo-600'; icon.value = 'BookOpen'; sortOrder.value = 0; + isTrackable.value = false; } errorMessage.value = ''; @@ -114,6 +118,7 @@ const submitForm = () => { tailwind_format: tailwindFormat.value, icon: icon.value, sort_order: sortOrder.value, + is_trackable: isTrackable.value, }; if (props.subject) { @@ -394,6 +399,32 @@ const submitForm = () => { class="w-full rounded-lg border border-slate-300 bg-white px-2.5 py-1.5 font-mono text-xs text-slate-900 placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500" /> + + +
+ +
diff --git a/resources/js/components/admin/NodeRow.vue b/resources/js/components/admin/NodeRow.vue index 9fcef54f..de135cca 100644 --- a/resources/js/components/admin/NodeRow.vue +++ b/resources/js/components/admin/NodeRow.vue @@ -33,12 +33,18 @@ const handleDelete = () => { -
+

{{ node.name }}

+ + Chapter +
diff --git a/resources/js/components/admin/SubjectCard.vue b/resources/js/components/admin/SubjectCard.vue index 4d04132f..c703e253 100644 --- a/resources/js/components/admin/SubjectCard.vue +++ b/resources/js/components/admin/SubjectCard.vue @@ -55,6 +55,13 @@ const handleDelete = () => { :status="subject.course" size="xs" /> + + + Trackable + diff --git a/resources/js/components/tracker/StopwatchWidget.vue b/resources/js/components/tracker/StopwatchWidget.vue new file mode 100644 index 00000000..367a284b --- /dev/null +++ b/resources/js/components/tracker/StopwatchWidget.vue @@ -0,0 +1,729 @@ + + + diff --git a/resources/js/components/tracker/StudyHeatmap.vue b/resources/js/components/tracker/StudyHeatmap.vue new file mode 100644 index 00000000..e35acf43 --- /dev/null +++ b/resources/js/components/tracker/StudyHeatmap.vue @@ -0,0 +1,860 @@ + + + diff --git a/resources/js/components/tracker/SubjectChecklist.vue b/resources/js/components/tracker/SubjectChecklist.vue new file mode 100644 index 00000000..d6beddc7 --- /dev/null +++ b/resources/js/components/tracker/SubjectChecklist.vue @@ -0,0 +1,479 @@ + + + diff --git a/resources/js/layouts/AppLayout.vue b/resources/js/layouts/AppLayout.vue index 604fb6f7..ac173b38 100644 --- a/resources/js/layouts/AppLayout.vue +++ b/resources/js/layouts/AppLayout.vue @@ -37,6 +37,7 @@ const DESKTOP_FOOTER_COMPONENTS = new Set([ 'Forum/Index', 'Donate', 'Projects', + 'Tracker/Index', 'Support', 'SupportMyTickets', 'ContributorGuide', diff --git a/resources/js/lib/navigation.ts b/resources/js/lib/navigation.ts index 38f9384d..760943ed 100644 --- a/resources/js/lib/navigation.ts +++ b/resources/js/lib/navigation.ts @@ -20,6 +20,14 @@ export const primaryNavItems: NavItem[] = [ url.startsWith('/ssc?'), showInBottom: true, }, + { + label: 'Tracker', + labelBn: 'স্টাডি ট্র্যাকার', + href: '/tracker', + icon: 'timer', + match: (url) => url.startsWith('/tracker'), + showInBottom: true, + }, { label: 'People', href: '/peers', @@ -47,7 +55,7 @@ export const primaryNavItems: NavItem[] = [ href: '/blogs', icon: 'menu_book', match: (url) => url.startsWith('/blogs'), - showInBottom: true, + showInBottom: false, }, { label: 'AI', diff --git a/resources/js/lib/useAuth.ts b/resources/js/lib/useAuth.ts index 946e8d17..fd81bd2b 100644 --- a/resources/js/lib/useAuth.ts +++ b/resources/js/lib/useAuth.ts @@ -23,9 +23,28 @@ export function useAuth() { * Returns true if user is authenticated, false otherwise. */ const requireAuth = ( - message = 'Please sign in to perform this action.', - action?: () => void, + messageOrAction: + | string + | (() => void) = 'Please sign in to perform this action.', + actionOrMessage?: string | (() => void), ): boolean => { + let message = 'Please sign in to perform this action.'; + let action: (() => void) | undefined; + + if (typeof messageOrAction === 'string') { + message = messageOrAction; + + if (typeof actionOrMessage === 'function') { + action = actionOrMessage; + } + } else if (typeof messageOrAction === 'function') { + action = messageOrAction; + + if (typeof actionOrMessage === 'string') { + message = actionOrMessage; + } + } + if (!isAuthenticated.value) { authModalMessage.value = message; showAuthModal.value = true; diff --git a/resources/js/pages/Profile.vue b/resources/js/pages/Profile.vue index ed33d26f..a58f23f7 100644 --- a/resources/js/pages/Profile.vue +++ b/resources/js/pages/Profile.vue @@ -1,5 +1,5 @@ + + diff --git a/resources/js/pages/User/Show.vue b/resources/js/pages/User/Show.vue index 8543fd1d..c0499263 100644 --- a/resources/js/pages/User/Show.vue +++ b/resources/js/pages/User/Show.vue @@ -8,6 +8,7 @@ import { ArrowUpRight, Calendar, CheckCircle2, + ChevronRight, Edit3, Eye, Facebook, @@ -30,6 +31,12 @@ import { import { computed, ref, watch } from 'vue'; import BaseModal from '@/components/BaseModal.vue'; import EmptyState from '@/components/EmptyState.vue'; +import SubjectIcon from '@/components/SubjectIcon.vue'; +import StudyHeatmap from '@/components/tracker/StudyHeatmap.vue'; +import type { + HeatmapItem, + TrackerStats, +} from '@/components/tracker/StudyHeatmap.vue'; import UserListItem from '@/components/UserListItem.vue'; import VerifiedBadge from '@/components/VerifiedBadge.vue'; import { formatTimeAgo } from '@/lib/useDate'; @@ -41,6 +48,7 @@ const props = defineProps<{ username: string; about: string | null; institution: string | null; + curriculum?: 'hsc' | 'ssc'; image_url: string | null; facebook: string | null; instagram: string | null; @@ -48,13 +56,6 @@ const props = defineProps<{ created_at: string; is_verified?: boolean; }; - stats?: { - questionsCount: number; - answersCount: number; - blogsCount: number; - sharedResourcesCount: number; - totalBlogViews: number; - }; appreciationsCount: number; appreciatingCount: number; isAppreciated: boolean; @@ -201,6 +202,28 @@ const props = defineProps<{ about: string | null; is_verified?: boolean; }>; + studyHeatmap?: { + heatmapData: HeatmapItem[]; + stats: TrackerStats; + }; + syllabusProgress?: { + course: string; + overallPercent: number; + completedChapters: number; + totalChapters: number; + subjects: Array<{ + id: number; + name: string; + english_name?: string | null; + slug: string; + course: string; + tailwind_format: string; + icon: string; + completed: number; + total: number; + percent: number; + }>; + }; }>(); const page = usePage(); @@ -209,6 +232,7 @@ const isOwnProfile = computed( () => currentUser.value?.id === props.profileUser.id, ); +const showSyllabusModal = ref(false); const activeTab = ref<'forum' | 'blogs' | 'activity'>('forum'); const forumSubTab = ref<'questions' | 'answers'>('questions'); @@ -652,113 +676,79 @@ const timeAgo = formatTimeAgo; - + + + + +
+
+
+
+
+ +
+
+

+ {{ subj.name }} +

+

+ {{ subj.english_name }} +

+
+
+ +
+ + {{ subj.percent }}% + +

+ {{ subj.completed }}/{{ subj.total }} Chapters +

+
+
+ + +
+
+
+
+
+ + + diff --git a/routes/web.php b/routes/web.php index 007873ba..cbf7bc9c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Http\Controllers\ProfileController; use App\Http\Controllers\ResourceController; use App\Http\Controllers\ShortUrlController; +use App\Http\Controllers\StudyTrackerController; use App\Http\Controllers\SubjectController; use App\Http\Controllers\SupportTicketController; use App\Http\Controllers\UserProfileController; @@ -34,6 +35,10 @@ Route::post('/blogs/{blog}/comments', [BlogController::class, 'storeComment'])->name('blogs.comments.store'); Route::delete('/blogs/comments/{comment}', [BlogController::class, 'destroyComment'])->name('blogs.comments.destroy'); Route::post('/resources/{resource}/complete', [ResourceController::class, 'toggleComplete'])->name('resources.complete'); + Route::post('/tracker/nodes/{node}/toggle', [StudyTrackerController::class, 'toggleNode'])->name('tracker.nodes.toggle'); + Route::post('/tracker/log-time', [StudyTrackerController::class, 'logTime'])->name('tracker.log-time'); + Route::post('/tracker/reset-today', [StudyTrackerController::class, 'resetToday'])->name('tracker.reset-today'); + Route::post('/tracker/curriculum', [StudyTrackerController::class, 'updateCurriculum'])->name('tracker.curriculum'); Route::post('/nodes/{node}/vote', [NodeController::class, 'vote'])->name('nodes.vote'); Route::post('/u/{user}/appreciate', [UserProfileController::class, 'toggleAppreciate'])->name('user.appreciate'); Route::post('/u/{user}/poke', [UserProfileController::class, 'poke'])->name('user.poke'); @@ -109,6 +114,7 @@ Route::get('/forum/questions/{post:slug}', [ForumController::class, 'show'])->name('forum.show'); Route::get('/chat', [ChatController::class, 'index'])->name('chat.index'); Route::get('/peers', [PeerController::class, 'index'])->name('peers.index'); + Route::get('/tracker', [StudyTrackerController::class, 'index'])->name('tracker.index'); Route::get('/u/{username}', [UserProfileController::class, 'show'])->name('user.profile'); Route::get('/', [SubjectController::class, 'index']) diff --git a/tests/Feature/StudyTrackerTest.php b/tests/Feature/StudyTrackerTest.php new file mode 100644 index 00000000..5aa2d49b --- /dev/null +++ b/tests/Feature/StudyTrackerTest.php @@ -0,0 +1,250 @@ + 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1', + 'slug' => 'chapter-1', + ]); + + $this->get('/tracker') + ->assertOk(); +}); + +test('authenticated user can view tracker page with stats and completions', function () { + $user = User::factory()->create(); + $subject = Subject::create([ + 'name' => 'Chemistry', + 'slug' => 'chemistry', + 'course' => 'hsc', + 'tailwind_format' => 'bg-emerald-500', + 'icon' => 'flask', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Organic Chemistry', + 'slug' => 'organic-chem', + ]); + NodeCompletion::create([ + 'user_id' => $user->id, + 'node_id' => $node->id, + ]); + DailyStudyLog::create([ + 'user_id' => $user->id, + 'study_date' => Carbon::today()->format('Y-m-d'), + 'total_seconds' => 3600, + ]); + + $this->actingAs($user) + ->get('/tracker') + ->assertOk(); +}); + +test('guests cannot toggle node completion or log study time', function () { + $subject = Subject::create([ + 'name' => 'Math', + 'slug' => 'math', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'calculator', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Calculus', + 'slug' => 'calculus', + ]); + + $this->post("/tracker/nodes/{$node->id}/toggle") + ->assertRedirect(); + + $this->post('/tracker/log-time', ['seconds' => 1200]) + ->assertRedirect(); + + expect(NodeCompletion::count())->toBe(0); + expect(DailyStudyLog::count())->toBe(0); +}); + +test('authenticated user can toggle node completion multiple times (idempotent toggling)', function () { + $user = User::factory()->create(); + $subject = Subject::create([ + 'name' => 'Biology', + 'slug' => 'biology', + 'course' => 'hsc', + 'tailwind_format' => 'bg-emerald-500', + 'icon' => 'dna', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Cell Structure', + 'slug' => 'cell-structure', + ]); + + // First toggle: complete + $this->actingAs($user) + ->post("/tracker/nodes/{$node->id}/toggle") + ->assertRedirect(); + + $this->assertDatabaseHas('node_completions', [ + 'user_id' => $user->id, + 'node_id' => $node->id, + ]); + + // Second toggle: uncomplete + $this->actingAs($user) + ->post("/tracker/nodes/{$node->id}/toggle") + ->assertRedirect(); + + $this->assertDatabaseMissing('node_completions', [ + 'user_id' => $user->id, + 'node_id' => $node->id, + ]); +}); + +test('authenticated user can log study time cumulatively for today', function () { + $user = User::factory()->create(); + + // Log 25 minutes + $this->actingAs($user) + ->post('/tracker/log-time', ['seconds' => 1500]) + ->assertRedirect(); + + $this->assertDatabaseHas('daily_study_logs', [ + 'user_id' => $user->id, + 'study_date' => Carbon::today()->format('Y-m-d'), + 'total_seconds' => 1500, + ]); + + // Increment with another 15 minutes + $this->actingAs($user) + ->post('/tracker/log-time', ['seconds' => 900]) + ->assertRedirect(); + + $this->assertDatabaseHas('daily_study_logs', [ + 'user_id' => $user->id, + 'study_date' => Carbon::today()->format('Y-m-d'), + 'total_seconds' => 2400, + ]); +}); + +test('tracker only includes trackable subjects and trackable nodes flatly', function () { + $trackableSubject = Subject::create([ + 'name' => 'Trackable Subject', + 'slug' => 'trackable-subject', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + 'is_trackable' => true, + ]); + + $untrackableSubject = Subject::create([ + 'name' => 'Untrackable Subject', + 'slug' => 'untrackable-subject', + 'course' => 'hsc', + 'tailwind_format' => 'bg-slate-500', + 'icon' => 'atom', + 'is_trackable' => false, + ]); + + $rootFolderUntrackable = Node::create([ + 'subject_id' => $trackableSubject->id, + 'name' => 'Poem Folder', + 'slug' => 'poem-folder', + 'is_trackable' => false, + ]); + + $childChapterTrackable = Node::create([ + 'subject_id' => $trackableSubject->id, + 'parent_id' => $rootFolderUntrackable->id, + 'name' => 'Real Chapter 1', + 'slug' => 'real-chapter-1', + 'is_trackable' => true, + ]); + + $response = $this->get('/tracker?course=hsc'); + $response->assertOk(); + + $page = $response->original->getData()['page']; + $subjects = $page['props']['subjects']; + + expect(count($subjects))->toBe(1); + expect($subjects[0]['id'])->toBe($trackableSubject->id); + expect(count($subjects[0]['nodes']))->toBe(1); + expect($subjects[0]['nodes'][0]['id'])->toBe($childChapterTrackable->id); + expect($subjects[0]['nodes'][0]['name'])->toBe('Real Chapter 1'); +}); + +test('authenticated user can reset today study time', function () { + $user = User::factory()->create(); + $todayStr = Carbon::today()->format('Y-m-d'); + + DailyStudyLog::create([ + 'user_id' => $user->id, + 'study_date' => $todayStr, + 'total_seconds' => 5000, + ]); + + $this->actingAs($user) + ->post('/tracker/reset-today') + ->assertRedirect(); + + $log = DailyStudyLog::where('user_id', $user->id)->where('study_date', $todayStr)->first(); + expect($log->total_seconds)->toBe(0); +}); + +test('switching curriculum updates user profile and clears full chapter track', function () { + $user = User::factory()->create(['curriculum' => 'hsc']); + + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + 'is_trackable' => true, + ]); + + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Vector', + 'slug' => 'vector', + 'is_trackable' => true, + ]); + + NodeCompletion::create([ + 'user_id' => $user->id, + 'node_id' => $node->id, + ]); + + expect(NodeCompletion::where('user_id', $user->id)->count())->toBe(1); + + $this->actingAs($user) + ->post('/tracker/curriculum', ['curriculum' => 'ssc']) + ->assertRedirect('/tracker'); + + expect($user->fresh()->curriculum)->toBe('ssc'); + expect(NodeCompletion::where('user_id', $user->id)->count())->toBe(0); +}); + +test('tracker renders user curriculum for authenticated user', function () { + $user = User::factory()->create(['curriculum' => 'ssc']); + + $response = $this->actingAs($user)->get('/tracker'); + $response->assertOk(); + + $page = $response->original->getData()['page']; + expect($page['props']['course'])->toBe('ssc'); +}); diff --git a/tests/Feature/UserActivityPrivacyTest.php b/tests/Feature/UserActivityPrivacyTest.php index 72507299..28ceae32 100644 --- a/tests/Feature/UserActivityPrivacyTest.php +++ b/tests/Feature/UserActivityPrivacyTest.php @@ -20,7 +20,6 @@ ->assertInertia(fn ($page) => $page ->component('User/Show') ->where('isLocked', false) - ->where('stats.questionsCount', 1) ->has('forumPosts', 1) ); }); @@ -45,7 +44,6 @@ ->component('User/Show') ->where('isLocked', true) ->where('lockReason', 'private') - ->missing('stats') ->missing('forumPosts') ); }); @@ -67,7 +65,6 @@ ->assertInertia(fn ($page) => $page ->component('User/Show') ->where('isLocked', false) - ->where('stats.questionsCount', 1) ->has('forumPosts', 1) ); }); @@ -92,7 +89,6 @@ ->component('User/Show') ->where('isLocked', true) ->where('lockReason', 'appreciators_only') - ->missing('stats') ->missing('forumPosts') ); }); @@ -117,7 +113,6 @@ ->assertInertia(fn ($page) => $page ->component('User/Show') ->where('isLocked', false) - ->where('stats.questionsCount', 1) ->has('forumPosts', 1) ); }); diff --git a/tests/Feature/UserProfileTest.php b/tests/Feature/UserProfileTest.php index 666d383e..d9c01391 100644 --- a/tests/Feature/UserProfileTest.php +++ b/tests/Feature/UserProfileTest.php @@ -5,6 +5,7 @@ use App\Models\ForumAnswer; use App\Models\ForumPost; use App\Models\Node; +use App\Models\NodeCompletion; use App\Models\Subject; use App\Models\User; use Illuminate\Support\Facades\Storage; @@ -58,7 +59,6 @@ $response->assertInertia(fn ($page) => $page ->component('User/Show') ->where('profileUser.is_verified', true) - ->where('stats.blogsCount', 1) ->has('blogs', 1) ->where('blogs.0.title', 'Calculus Masterclass') ); @@ -99,8 +99,6 @@ $response->assertOk(); $response->assertInertia(fn ($page) => $page ->component('User/Show') - ->where('stats.questionsCount', 1) - ->where('stats.answersCount', 1) ->has('forumPosts', 1) ->where('forumPosts.0.title', 'How to calculate cross product angle?') ->has('forumAnswers', 1) @@ -146,8 +144,6 @@ $response->assertOk(); $response->assertInertia(fn ($page) => $page ->component('User/Show') - ->where('stats.questionsCount', 0) - ->where('stats.answersCount', 0) ->has('forumPosts', 0) ->has('forumAnswers', 0) ->has('recentActivities.forum_posts', 0) @@ -276,3 +272,65 @@ $user2 = User::factory()->create(['image_path' => null]); expect($user2->image_url)->toBeNull(); }); + +test('public profile accurately displays syllabus progress and subject breakdown', function () { + $user = User::factory()->create([ + 'username' => 'syllabus_master', + 'curriculum' => 'hsc', + ]); + + $subject1 = Subject::create([ + 'name' => 'Physics 1st Paper', + 'slug' => 'physics-1st', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + 'is_trackable' => true, + ]); + + $subject2 = Subject::create([ + 'name' => 'Chemistry 1st Paper', + 'slug' => 'chemistry-1st', + 'course' => 'hsc', + 'tailwind_format' => 'bg-emerald-500', + 'icon' => 'flask', + 'is_trackable' => true, + ]); + + $node1 = Node::create([ + 'subject_id' => $subject1->id, + 'name' => 'Vector', + 'slug' => 'vector', + 'is_trackable' => true, + ]); + + $node2 = Node::create([ + 'subject_id' => $subject1->id, + 'name' => 'Dynamics', + 'slug' => 'dynamics', + 'is_trackable' => true, + ]); + + $node3 = Node::create([ + 'subject_id' => $subject2->id, + 'name' => 'Qualitative Chemistry', + 'slug' => 'qualitative-chem', + 'is_trackable' => true, + ]); + + // Complete 1 out of 2 for Physics, 1 out of 1 for Chemistry => 2 / 3 total = 67% + NodeCompletion::create(['user_id' => $user->id, 'node_id' => $node1->id]); + NodeCompletion::create(['user_id' => $user->id, 'node_id' => $node3->id]); + + $response = $this->get('/u/syllabus_master'); + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('User/Show') + ->where('syllabusProgress.overallPercent', 67) + ->where('syllabusProgress.completedChapters', 2) + ->where('syllabusProgress.totalChapters', 3) + ->has('syllabusProgress.subjects', 2) + ->where('syllabusProgress.subjects.0.percent', 50) + ->where('syllabusProgress.subjects.1.percent', 100) + ); +});