diff --git a/app/Http/Controllers/ForumController.php b/app/Http/Controllers/ForumController.php index c4b24077..9b5ccba7 100644 --- a/app/Http/Controllers/ForumController.php +++ b/app/Http/Controllers/ForumController.php @@ -65,8 +65,9 @@ public function index(Request $request): Response } $subjects = Cache::remember('forum_filter_subjects', now()->addDay(), function () { - return Subject::select('id', 'name', 'course', 'slug') - ->with(['nodes' => fn ($q) => $q->whereNull('parent_id')->select('id', 'subject_id', 'name', 'slug')->orderBy('sort_order')]) + return Subject::where('is_trackable', true) + ->select('id', 'name', 'course', 'slug') + ->with(['nodes' => fn ($q) => $q->where('is_trackable', true)->select('id', 'subject_id', 'name', 'slug')->orderBy('sort_order')]) ->orderBy('sort_order') ->get() ->toArray(); @@ -182,8 +183,9 @@ public function create(): Response|RedirectResponse } $subjects = Cache::remember('forum_filter_subjects', now()->addDay(), function () { - return Subject::select('id', 'name', 'course', 'slug') - ->with(['nodes' => fn ($q) => $q->whereNull('parent_id')->select('id', 'subject_id', 'name', 'slug')->orderBy('sort_order')]) + return Subject::where('is_trackable', true) + ->select('id', 'name', 'course', 'slug') + ->with(['nodes' => fn ($q) => $q->where('is_trackable', true)->select('id', 'subject_id', 'name', 'slug')->orderBy('sort_order')]) ->orderBy('sort_order') ->get() ->toArray(); diff --git a/app/Http/Controllers/StudyTrackerController.php b/app/Http/Controllers/StudyTrackerController.php index 17f647cf..77f165d8 100644 --- a/app/Http/Controllers/StudyTrackerController.php +++ b/app/Http/Controllers/StudyTrackerController.php @@ -18,17 +18,41 @@ public function index(Request $request): Response $user = $request->user(); $course = $user?->curriculum ?: 'hsc'; $group = $user?->group ?: 'science'; + $optionalSubjectIds = (array) ($user?->optional_subject_ids ?? []); - $subjects = Subject::where('course', $course) + $subjectsQuery = Subject::where('course', $course) ->where('is_trackable', true) - ->whereIn('group', [$group, 'common']) - ->orderBy('sort_order', 'asc') + ->where(function ($query) use ($group, $optionalSubjectIds) { + $query->where(function ($q) use ($group) { + $q->where('is_optional', false) + ->whereIn('group', [$group, 'common']); + }); + + if (! empty($optionalSubjectIds)) { + $query->orWhere(function ($q) use ($group, $optionalSubjectIds) { + $q->where('is_optional', true) + ->whereIn('group', [$group, 'common']) + ->whereIn('id', $optionalSubjectIds); + }); + } + }) + ->orderBy('sort_order', 'asc'); + + $subjects = $subjectsQuery ->with(['nodes' => function ($query) { $query->where('is_trackable', true) + ->with('parent:id,name,sort_order') ->orderBy('sort_order', 'asc') - ->select('id', 'subject_id', 'name', 'slug', 'sort_order', 'weight'); + ->select('id', 'subject_id', 'parent_id', 'name', 'slug', 'sort_order', 'weight'); }]) - ->get(['id', 'name', 'slug', 'course', 'group', 'sort_order']) + ->get(['id', 'name', 'slug', 'course', 'group', 'sort_order', 'is_optional']) + ->toArray(); + + // All trackable optional subjects for curriculum modal (so user can toggle optional subjects on/off) + $groupSubjects = Subject::where('is_trackable', true) + ->where('is_optional', true) + ->orderBy('sort_order', 'asc') + ->get(['id', 'name', 'course', 'group', 'is_optional']) ->toArray(); if (! $user) { @@ -36,6 +60,8 @@ public function index(Request $request): Response 'course' => $course, 'group' => $group, 'subjects' => $subjects, + 'optionalSubjectIds' => [], + 'optionalSubjects' => $groupSubjects, 'completedNodeIds' => [], 'todaySeconds' => 0, 'stats' => [ @@ -64,6 +90,8 @@ public function index(Request $request): Response 'course' => $course, 'group' => $group, 'subjects' => $subjects, + 'optionalSubjectIds' => $optionalSubjectIds, + 'optionalSubjects' => $groupSubjects, 'completedNodeIds' => $completedNodeIds, 'todaySeconds' => $todaySeconds, 'stats' => $studyData['stats'], @@ -147,9 +175,15 @@ public function updateCurriculum(Request $request) $validated = $request->validate([ 'curriculum' => 'required|string|in:hsc,ssc', 'group' => 'required|string|in:science,humanities,commerce', + 'optional_subject_ids' => 'nullable|array', + 'optional_subject_ids.*' => 'integer|exists:subjects,id', ]); - $request->user()->update($validated); + $request->user()->update([ + 'curriculum' => $validated['curriculum'], + 'group' => $validated['group'], + 'optional_subject_ids' => $validated['optional_subject_ids'] ?? [], + ]); return redirect()->route('tracker.index') ->with('success', 'Curriculum updated to '.strtoupper($validated['curriculum']).' ('.ucfirst($validated['group']).').'); diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 2e78090a..49cbe3c3 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -393,14 +393,32 @@ private function getSyllabusProgress(User $user): array { $course = $user->curriculum ?: 'hsc'; $group = $user->group ?: 'science'; - $trackableSubjects = Subject::where('course', $course) + $optionalSubjectIds = (array) ($user->optional_subject_ids ?? []); + + $subjectsQuery = Subject::where('course', $course) ->where('is_trackable', true) - ->whereIn('group', [$group, 'common']) - ->orderBy('sort_order', 'asc') + ->where(function ($query) use ($group, $optionalSubjectIds) { + $query->where(function ($q) use ($group) { + $q->where('is_optional', false) + ->whereIn('group', [$group, 'common']); + }); + + if (! empty($optionalSubjectIds)) { + $query->orWhere(function ($q) use ($group, $optionalSubjectIds) { + $q->where('is_optional', true) + ->whereIn('group', [$group, 'common']) + ->whereIn('id', $optionalSubjectIds); + }); + } + }) + ->orderBy('sort_order', 'asc'); + + $trackableSubjects = $subjectsQuery ->with(['nodes' => function ($query) { $query->where('is_trackable', true) + ->with('parent:id,name,sort_order') ->orderBy('sort_order', 'asc') - ->select('id', 'subject_id', 'name', 'slug', 'sort_order', 'weight'); + ->select('id', 'subject_id', 'parent_id', 'name', 'slug', 'sort_order', 'weight'); }]) ->get(['id', 'name', 'slug', 'course', 'tailwind_format', 'icon', 'sort_order']); @@ -439,6 +457,12 @@ private function getSyllabusProgress(User $user): array 'name' => $node->name, 'weight' => $nodeWeight, 'is_completed' => $isDone, + 'parent_id' => $node->parent_id, + 'parent' => $node->parent ? [ + 'id' => $node->parent->id, + 'name' => $node->parent->name, + 'sort_order' => $node->parent->sort_order, + ] : null, ]; } diff --git a/app/Models/Subject.php b/app/Models/Subject.php index 32be626d..41691ec6 100644 --- a/app/Models/Subject.php +++ b/app/Models/Subject.php @@ -12,6 +12,7 @@ protected function casts(): array { return [ 'is_trackable' => 'boolean', + 'is_optional' => 'boolean', ]; } diff --git a/app/Models/User.php b/app/Models/User.php index d008dffa..99e9c875 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -59,6 +59,7 @@ class User extends Authenticatable 'facebook', 'instagram', 'github', + 'optional_subject_ids', 'last_active_at', 'last_seen_at', ]; @@ -121,6 +122,7 @@ protected function casts(): array 'is_verified' => 'boolean', 'priority' => 'integer', 'allow_pokes' => 'boolean', + 'optional_subject_ids' => 'array', ]; } diff --git a/database/migrations/2026_10_13_155150_add_is_optional_to_subjects_and_optional_subject_ids_to_users.php b/database/migrations/2026_10_13_155150_add_is_optional_to_subjects_and_optional_subject_ids_to_users.php new file mode 100644 index 00000000..7ef66a88 --- /dev/null +++ b/database/migrations/2026_10_13_155150_add_is_optional_to_subjects_and_optional_subject_ids_to_users.php @@ -0,0 +1,63 @@ +boolean('is_optional')->default(false)->after('is_trackable'); + }); + + Schema::table('users', function (Blueprint $table) { + $table->json('optional_subject_ids')->nullable()->after('group'); + }); + + // Seed initial known optional subjects by unique slug: + $optionalSlugs = [ + // HSC Science + 'hsc-hmath-1st', + 'hsc-hmath-2nd', + 'hsc-biology-1st', + 'hsc-biology-2nd', + // SSC Science + 'ssc-biology', + 'ssc-higher-math', + // HSC Commerce + 'hsc-finance-1st', + 'hsc-finance-2nd', + 'hsc-marketing-1st', + 'hsc-marketing-2nd', + // HSC Humanities + 'hsc-economics-1st', + 'hsc-economics-2nd', + 'hsc-logic-1st', + 'hsc-logic-2nd', + 'hsc-islamic-history-1st', + 'hsc-islamic-history-2nd', + ]; + + DB::table('subjects')->whereIn('slug', $optionalSlugs)->update(['is_optional' => true]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('optional_subject_ids'); + }); + + Schema::table('subjects', function (Blueprint $table) { + $table->dropColumn('is_optional'); + }); + } +}; diff --git a/resources/js/components/tracker/SubjectChecklist.vue b/resources/js/components/tracker/SubjectChecklist.vue index be2da76a..e0d224a0 100644 --- a/resources/js/components/tracker/SubjectChecklist.vue +++ b/resources/js/components/tracker/SubjectChecklist.vue @@ -1,25 +1,34 @@ diff --git a/resources/js/components/tracker/SyllabusBreakdownModal.vue b/resources/js/components/tracker/SyllabusBreakdownModal.vue index 8a0b5a45..2bc85d99 100644 --- a/resources/js/components/tracker/SyllabusBreakdownModal.vue +++ b/resources/js/components/tracker/SyllabusBreakdownModal.vue @@ -17,6 +17,12 @@ export interface SyllabusChapter { name: string; weight: number; is_completed: boolean; + parent_id?: number | null; + parent?: { + id: number; + name: string; + sort_order?: number; + } | null; } export interface SyllabusSubject { @@ -32,6 +38,11 @@ export interface SyllabusSubject { chapters?: SyllabusChapter[]; } +export interface SyllabusChapterGroup { + name: string | null; + chapters: SyllabusChapter[]; +} + export interface SyllabusProgressData { course: string; group?: string; @@ -67,6 +78,102 @@ const toggleSubjectExpanded = (subjId: number) => { } }; +function getSubjectChapterGroups( + subj: SyllabusSubject, +): SyllabusChapterGroup[] { + const chapters = subj.chapters || []; + const hasAnyParent = chapters.some((c) => c.parent_id && c.parent?.name); + + if (!hasAnyParent) { + return [{ name: null, chapters }]; + } + + const groupsMap = new Map< + string, + { sort: number; chapters: SyllabusChapter[] } + >(); + const noParentChapters: SyllabusChapter[] = []; + + for (const chap of chapters) { + if (chap.parent?.name) { + const parentName = chap.parent.name; + const parentSort = chap.parent.sort_order ?? 999; + + if (!groupsMap.has(parentName)) { + groupsMap.set(parentName, { sort: parentSort, chapters: [] }); + } + + groupsMap.get(parentName)!.chapters.push(chap); + } else { + noParentChapters.push(chap); + } + } + + const result: SyllabusChapterGroup[] = Array.from(groupsMap.entries()) + .sort((a, b) => a[1].sort - b[1].sort) + .map(([name, data]) => ({ name, chapters: data.chapters })); + + if (noParentChapters.length > 0) { + result.push({ name: 'অন্যান্য অধ্যায়', chapters: noParentChapters }); + } + + return result; +} + +// Collapsed state for sub-groups with names (default: 1st group expanded (index 0), others collapsed) +const collapsedModalSubgroups = ref>(new Set()); + +function isModalSubgroupExpanded( + subjId: number, + groupIndex: number, + groupName: string | null, +): boolean { + if (!groupName) { + return true; + } + + const key = `${subjId}_${groupName}`; + + if (collapsedModalSubgroups.value.has(key)) { + return false; + } + + // Initially expand 1st group (index 0), others collapsed + if (groupIndex === 0) { + return true; + } + + const explicitExpandedKey = `exp_${key}`; + + return collapsedModalSubgroups.value.has(explicitExpandedKey); +} + +function toggleModalSubgroup( + subjId: number, + groupIndex: number, + groupName: string | null, +) { + if (!groupName) { + return; + } + + const key = `${subjId}_${groupName}`; + const explicitExpandedKey = `exp_${key}`; + const currentlyExpanded = isModalSubgroupExpanded( + subjId, + groupIndex, + groupName, + ); + + if (currentlyExpanded) { + collapsedModalSubgroups.value.add(key); + collapsedModalSubgroups.value.delete(explicitExpandedKey); + } else { + collapsedModalSubgroups.value.delete(key); + collapsedModalSubgroups.value.add(explicitExpandedKey); + } +} + const formatGroupName = (group?: string) => { if (!group) { return ''; @@ -242,84 +349,136 @@ function toggleChapterCompletion(subj: SyllabusSubject, chap: SyllabusChapter) { /> - +
-
+ +
+
+ + {{ group.name }} +
- -
+ {{ + group.chapters.filter((c) => c.is_completed) + .length + }}/{{ group.chapters.length }} +
- +
+
- {{ chap.name }} - -
+
+ + +
+ - - {{ - chap.weight === 3 - ? '(Large Chapter)' - : chap.weight === 1 - ? '(Small Chapter)' - : '(Normal Chapter)' - }} - + + {{ chap.name }} + +
+ + + {{ + chap.weight === 3 + ? '(Large Chapter)' + : chap.weight === 1 + ? '(Small Chapter)' + : '(Normal Chapter)' + }} + +
+
diff --git a/resources/js/pages/Tracker/Index.vue b/resources/js/pages/Tracker/Index.vue index df293c61..a71fb908 100644 --- a/resources/js/pages/Tracker/Index.vue +++ b/resources/js/pages/Tracker/Index.vue @@ -12,6 +12,14 @@ interface Props { course: 'hsc' | 'ssc'; group: 'science' | 'humanities' | 'commerce'; subjects: SubjectItem[]; + optionalSubjectIds?: number[]; + optionalSubjects?: Array<{ + id: number; + name: string; + course: string; + group: string; + is_optional: boolean; + }>; completedNodeIds: number[]; todaySeconds: number; } @@ -107,6 +115,8 @@ function handleAuthRequired( :course="course" :group="group" :subjects="subjects" + :optional-subject-ids="optionalSubjectIds" + :optional-subjects="optionalSubjects" :completed-node-ids="completedNodeIds" :is-authenticated="isAuthenticated" @require-auth="handleAuthRequired"