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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Stopwatch
+
+
+
+
+
+
+
+ Today:
+
+ {{ todayFormatted }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Running
+
+
+ Paused
+
+ Ready
+
+
+ You can safely switch tabs or minimize your browser
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Read when offline?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Save Study Session
+
+
+
+
+
+
+
+
+ {{ pendingDurationFormatted }}
+
+
+ পুরো সময়টা কি আসলেই পড়াশোনা করেছেন? নিজের প্রতি সৎ
+ থাকুন — নিজের ভবিষ্যতের সাথে ফাঁকি দেবেন না! 🎯
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Log Study Time
+
+
+
+
+
+ Add study minutes completed today:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Clear Today's Time
+
+
+
+
+
+ Are you sure you want to reset today's recorded
+ study time ({{ todayFormatted }}) back to
+ 0 mins?
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ Study Activity
+
+
+
+ {{ selectedRangeFormatted }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatDuration(activeHover.seconds) }}
+
+
+ {{ formatDateDisplay(activeHover.date) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Daily Study Log
+
+
+
+
+
+
+ {{ formatDateFull(selectedDay.date) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Study Time Levels
+
+
+
+
+
+
+
+
+ Level 0
+
+
0 mins
+
+
+
+
+
+ Level 1
+
+
< 2 hours
+
+
+
+
+
+ Level 2
+
+
2 – 4 hours
+
+
+
+
+
+ Level 3
+
+
4 – 6 hours
+
+
+
+
+
+ Level 4
+
+
6 – 8 hours
+
+
+
+
+
+ Level 5
+
+
8+ hours
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Syllabus Chapter Checklist
+
+
+
+ পড়া শেষ হওয়া অধ্যায়গুলোতে টিক দিয়ে সিলেবাসের অগ্রগতি
+ ট্র্যাক করুন
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Course Completion
+
+
+ {{ totalCompletedChapters }} /
+ {{ totalChapters }} Chapters ({{ overallPercentage }}%)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ subject.name }}
+
+
+ {{ subject.english_name }} •
+
+ {{
+ getSubjectProgress(subject).completed
+ }}/{{
+ getSubjectProgress(subject).total
+ }}
+ Chapters
+
+
+
+
+
+
+
+
+ {{ getSubjectProgress(subject).percent }}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No chapters available for this subject yet.
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ node.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
সতর্কতা
+
+ কারিকুলাম পরিবর্তন করলে স্টাডি ট্র্যাকারের টিক দেওয়া
+ সকল অধ্যায়ের অগ্রগতি রিসেট হয়ে যাবে।
+
+
+
+
+
+ আপনি কি নিশ্চিতভাবে কারিকুলাম
+ {{ pendingCurriculum }}
+ এ পরিবর্তন করতে চান?
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ Study Tracker - HSC Stack
+
+
+
+
+
+
+
+
+
+
+
+
+ Study Tracker
+
+
+ দৈনিক পড়ার সময় ট্র্যাক করুন ও সিলেবাসের অগ্রগতি রাখুন
+
+
+
+
+
+
+
+
+ View activity
+
+
+
+
+ Log in to save your study sessions & progress
+
+
+
Log in
+
+
+
+
+
+
+
+
+
+
+
+
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;
-
+
-
-
-
-
-
-
-
-
-
-
- {{ stats?.questionsCount ?? 0 }}
-
-
- Questions
-
-
-
-
+
+
+
+
+
+ {{ syllabusProgress.overallPercent }}%
+
-
-
-
-
-
-
-
-
- {{ stats?.answersCount ?? 0 }}
-
-
- Answers
-
-
-
-
+
·
-
-
-
-
-
-
-
-
- {{ stats?.blogsCount ?? 0 }}
-
-
- Articles
-
-
-
+
+ Syllabus Completed ({{
+ syllabusProgress.course.toUpperCase()
+ }})
+
+
+
+ ({{ syllabusProgress.completedChapters }}/{{
+ syllabusProgress.totalChapters
+ }}
+ chapters)
+
-
-
-
+
+
+
-
-
-
-
- {{ stats?.sharedResourcesCount ?? 0 }}
-
-
- Shared Files
-
-
+ class="h-full rounded-full bg-gradient-to-r from-indigo-500 to-emerald-400 transition-all duration-500"
+ :style="{
+ width: `${syllabusProgress.overallPercent}%`,
+ }"
+ />
+
+
+
+
+
+
+
@@ -1803,4 +1793,87 @@ const timeAgo = formatTimeAgo;
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ subj.name }}
+
+
+ {{ subj.english_name }}
+
+
+
+
+
+
+
+ {{ 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)
+ );
+});