From 418dad1f59b4e24b870efbcc945453f88d637432 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:26:49 +0530 Subject: [PATCH 01/30] feat: implement AI-driven productivity scoring, analytics --- backend/src/app.js | 2 + backend/src/controllers/aiController.js | 34 ++++ backend/src/models/AiInsight.js | 47 ++++++ backend/src/routes/aiRoutes.js | 15 ++ backend/src/services/ai/insightEngine.js | 188 +++++++++++++++++++++++ 5 files changed, 286 insertions(+) create mode 100644 backend/src/controllers/aiController.js create mode 100644 backend/src/models/AiInsight.js create mode 100644 backend/src/routes/aiRoutes.js create mode 100644 backend/src/services/ai/insightEngine.js diff --git a/backend/src/app.js b/backend/src/app.js index cbdf4e8..411d66b 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -17,6 +17,7 @@ const seedRoutes = require("./routes/seedRoutes"); const feedbackRoutes = require("./routes/feedbackRoutes"); const gdprRoutes = require("./routes/gdprRoutes"); const cronRoutes = require("./routes/cronRoutes"); +const aiRoutes = require("./routes/aiRoutes"); const app = express(); app.set("trust proxy", 1); @@ -87,6 +88,7 @@ app.use("/api/admin", require("./admin/routes/adminRoutes")); // Cron endpoint: called by Vercel Cron in production; // node-cron handles the same job on traditional/local servers (see server.js). app.use("/api/cron", cronRoutes); +app.use("/api/ai", aiRoutes); app.get(["/favicon.ico", "/favicon.png"], (req, res) => res.status(204).end()); diff --git a/backend/src/controllers/aiController.js b/backend/src/controllers/aiController.js new file mode 100644 index 0000000..75771f8 --- /dev/null +++ b/backend/src/controllers/aiController.js @@ -0,0 +1,34 @@ +const asyncHandler = require("express-async-handler"); +const { aggregateUserStats } = require("../services/analytics/aggregator"); +const { + calculateProductivityScore, +} = require("../services/analytics/productivityScore"); +const { generateInsights } = require("../services/ai/insightEngine"); + +// @desc Get aggregated analytics summary + productivity score +// @route GET /api/ai/summary +// @access Private +const getAnalyticsSummary = asyncHandler(async (req, res) => { + const days = parseInt(req.query.days, 10) || 30; + const stats = await aggregateUserStats(req.user._id, days); + const score = calculateProductivityScore(stats, req.user.settings || {}); + + res.json({ + stats, + productivityScore: score.score, + scoreBreakdown: score.breakdown, + }); +}); + +// @desc Get AI-generated insights (cached 24h) +// @route GET /api/ai/insights +// @access Private +const getInsights = asyncHandler(async (req, res) => { + const result = await generateInsights(req.user._id, req.user.settings || {}); + res.json(result); +}); + +module.exports = { + getAnalyticsSummary, + getInsights, +}; diff --git a/backend/src/models/AiInsight.js b/backend/src/models/AiInsight.js new file mode 100644 index 0000000..07e8058 --- /dev/null +++ b/backend/src/models/AiInsight.js @@ -0,0 +1,47 @@ +const mongoose = require("mongoose"); + +const aiInsightSchema = mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: "User", + index: true, + }, + insights: { + type: [String], + default: [], + }, + recommendations: { + type: [String], + default: [], + }, + summary: { + type: String, + default: "", + }, + productivityScore: { + type: Number, + default: 0, + }, + scoreBreakdown: { + type: Object, + default: {}, + }, + stats: { + type: Object, + default: {}, + }, + generatedAt: { + type: Date, + default: Date.now, + }, + expiresAt: { + type: Date, + required: true, + index: { expires: 0 }, // TTL index — MongoDB auto-deletes when expiresAt passes + }, +}); + +const AiInsight = mongoose.model("AiInsight", aiInsightSchema); + +module.exports = AiInsight; diff --git a/backend/src/routes/aiRoutes.js b/backend/src/routes/aiRoutes.js new file mode 100644 index 0000000..23d36c4 --- /dev/null +++ b/backend/src/routes/aiRoutes.js @@ -0,0 +1,15 @@ +const express = require("express"); +const { protect } = require("../middleware/authMiddleware"); +const { apiLimiter } = require("../middleware/rateLimitMiddleware"); +const { + getAnalyticsSummary, + getInsights, +} = require("../controllers/aiController"); + +const router = express.Router(); + +// All AI routes require authentication + rate limiting +router.get("/summary", protect, apiLimiter, getAnalyticsSummary); +router.get("/insights", protect, apiLimiter, getInsights); + +module.exports = router; diff --git a/backend/src/services/ai/insightEngine.js b/backend/src/services/ai/insightEngine.js new file mode 100644 index 0000000..7023305 --- /dev/null +++ b/backend/src/services/ai/insightEngine.js @@ -0,0 +1,188 @@ +const AiInsight = require("../../models/AiInsight"); +const { aggregateUserStats } = require("../analytics/aggregator"); +const { + calculateProductivityScore, +} = require("../analytics/productivityScore"); +const { generate } = require("../llmService"); + +const CACHE_HOURS = 24; + +/** + * Build a compact prompt from pre-aggregated stats. + * Follows the architecture doc's prompt strategy: never send raw sessions. + */ +function buildInsightPrompt(stats, scoreResult) { + const peakHoursFormatted = (stats.patterns.peakHours || []) + .map((h) => { + const hour12 = h % 12 || 12; + const ampm = h < 12 ? "AM" : "PM"; + return `${hour12} ${ampm}`; + }) + .join(", "); + + return `You are a productivity coach analyzing a student's study data. + +User Statistics: +- Average focus session: ${stats.focus.avgDurationMin} minutes +- Session completion rate: ${stats.focus.completionRate}% +- Total focus this week: ${stats.focus.weeklyMinutes} minutes +- Current streak: ${stats.patterns.currentStreak} days +- Most productive hours: ${peakHoursFormatted || "not enough data"} +- Break frequency: ${stats.patterns.breakFrequency} breaks per focus session +- Task completion rate: ${stats.tasks.completionRate}% +- Productivity score: ${scoreResult.score}/100 + +Generate a JSON response with exactly this structure (no markdown, no code fences): +{ + "insights": ["insight1", "insight2", "insight3"], + "recommendations": ["recommendation1", "recommendation2"], + "summary": "one motivational sentence" +} + +Rules: +- Insights should be specific observations about the user's patterns (e.g., "You focus best between 7–10 PM") +- Recommendations should be actionable changes (e.g., "Try 35-minute sessions instead of 25") +- Summary should be encouraging and personalized +- Keep each item under 100 characters +- Be conversational, not robotic`; +} + +/** + * Parse the LLM response into structured data. + * Handles common issues: markdown fences, partial JSON, etc. + */ +function parseInsightResponse(text) { + try { + // Strip markdown code fences if present + let cleaned = text.trim(); + if (cleaned.startsWith("```")) { + cleaned = cleaned.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, ""); + } + const parsed = JSON.parse(cleaned); + + return { + insights: Array.isArray(parsed.insights) + ? parsed.insights.slice(0, 3) + : [], + recommendations: Array.isArray(parsed.recommendations) + ? parsed.recommendations.slice(0, 2) + : [], + summary: + typeof parsed.summary === "string" + ? parsed.summary + : "Keep up the great work!", + }; + } catch { + // Fallback if parsing fails + return { + insights: ["We're still analyzing your patterns — check back soon."], + recommendations: [ + "Complete a few more focus sessions for personalized tips.", + ], + summary: "Every session counts — keep going!", + }; + } +} + +/** + * Generate AI insights for a user. + * + * Flow: + * 1. Check cache → return if valid + * 2. Aggregate stats → build prompt → call LLM → parse + * 3. Save to cache → return + * + * If LLM fails, returns stale cache or a fallback. + */ +async function generateInsights(userId, userSettings = {}) { + // ── 1. Check cache ────────────────────────────────────────────── + const cached = await AiInsight.findOne({ + user: userId, + expiresAt: { $gt: new Date() }, + }) + .sort({ generatedAt: -1 }) + .lean(); + + if (cached) { + return { + insights: cached.insights, + recommendations: cached.recommendations, + summary: cached.summary, + productivityScore: cached.productivityScore, + scoreBreakdown: cached.scoreBreakdown, + stats: cached.stats, + generatedAt: cached.generatedAt, + fromCache: true, + }; + } + + // ── 2. Aggregate + Score ──────────────────────────────────────── + const stats = await aggregateUserStats(userId, 30); + const scoreResult = calculateProductivityScore(stats, userSettings); + + // ── 3. Call LLM ───────────────────────────────────────────────── + let parsed; + try { + const prompt = buildInsightPrompt(stats, scoreResult); + const llmResponse = await generate(prompt, null, { + max_tokens: 400, + temperature: 0.4, + }); + parsed = parseInsightResponse(llmResponse); + } catch (err) { + // LLM failed — try stale cache + const stale = await AiInsight.findOne({ user: userId }) + .sort({ generatedAt: -1 }) + .lean(); + + if (stale) { + return { + insights: stale.insights, + recommendations: stale.recommendations, + summary: stale.summary, + productivityScore: scoreResult.score, + scoreBreakdown: scoreResult.breakdown, + stats, + generatedAt: stale.generatedAt, + fromCache: true, + stale: true, + }; + } + + // No cache at all — return fallback + parsed = parseInsightResponse("invalid"); + } + + // ── 4. Save to cache ──────────────────────────────────────────── + const now = new Date(); + const expiresAt = new Date(now.getTime() + CACHE_HOURS * 60 * 60 * 1000); + + const saved = await AiInsight.findOneAndUpdate( + { user: userId }, + { + user: userId, + insights: parsed.insights, + recommendations: parsed.recommendations, + summary: parsed.summary, + productivityScore: scoreResult.score, + scoreBreakdown: scoreResult.breakdown, + stats, + generatedAt: now, + expiresAt, + }, + { upsert: true, new: true }, + ); + + return { + insights: saved.insights, + recommendations: saved.recommendations, + summary: saved.summary, + productivityScore: scoreResult.score, + scoreBreakdown: scoreResult.breakdown, + stats, + generatedAt: saved.generatedAt, + fromCache: false, + }; +} + +module.exports = { generateInsights, buildInsightPrompt, parseInsightResponse }; From dc1a56cbed5b8d3d87bedb694b8a6535e4c2b626 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:29:30 +0530 Subject: [PATCH 02/30] feat: implement AI-driven productivity insights and analytics --- backend/src/services/analytics/aggregator.js | 155 +++++++++++ .../services/analytics/productivityScore.js | 76 ++++++ .../components/dashboard/AiInsightsPanel.tsx | 249 ++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 backend/src/services/analytics/aggregator.js create mode 100644 backend/src/services/analytics/productivityScore.js create mode 100644 frontend/src/components/dashboard/AiInsightsPanel.tsx diff --git a/backend/src/services/analytics/aggregator.js b/backend/src/services/analytics/aggregator.js new file mode 100644 index 0000000..ae2fcf1 --- /dev/null +++ b/backend/src/services/analytics/aggregator.js @@ -0,0 +1,155 @@ +const Session = require("../../models/Session"); +const Task = require("../../models/Task"); +const { + subDays, + startOfDay, + format, + getHours, + differenceInCalendarDays, +} = require("date-fns"); + +/** + * Aggregate productivity statistics for a user. + * + * Returns pre-computed metrics that downstream services (insight engine, + * recommender, productivity score) consume. This function is the single + * source of truth for user analytics — AI services never query the + * database directly. + * + * @param {string} userId + * @param {number} [days=30] + * @returns {Promise} + */ +async function aggregateUserStats(userId, days = 30) { + const since = subDays(new Date(), days); + + // Fetch raw data + const [sessions, tasks] = await Promise.all([ + Session.find({ user: userId, startTime: { $gte: since } }).lean(), + Task.find({ user: userId }).lean(), + ]); + + const focusSessions = sessions.filter((s) => s.type === "focus"); + const breakSessions = sessions.filter((s) => s.type !== "focus"); + + // Basic counters + const totalFocusSessions = focusSessions.length; + const totalFocusSeconds = focusSessions.reduce( + (sum, s) => sum + (s.duration || 0), + 0, + ); + const avgFocusDurationMin = + totalFocusSessions > 0 + ? Math.round(totalFocusSeconds / totalFocusSessions / 60) + : 0; + + // Completion rate + const completedFocus = focusSessions.filter((s) => s.completed).length; + const completionRate = + totalFocusSessions > 0 + ? Math.round((completedFocus / totalFocusSessions) * 100) + : 0; + + // Peak productive hours (hour-of-day histogram, top 3) + const hourCounts = new Array(24).fill(0); + focusSessions.forEach((s) => { + if (s.startTime) { + hourCounts[getHours(new Date(s.startTime))] += 1; + } + }); + const peakHours = hourCounts + .map((count, hour) => ({ hour, count })) + .sort((a, b) => b.count - a.count) + .filter((h) => h.count > 0) + .slice(0, 3) + .map((h) => h.hour); + + // Break frequency + const breakFrequency = + totalFocusSessions > 0 + ? Math.round((breakSessions.length / totalFocusSessions) * 100) / 100 + : 0; + + // Current streak (consecutive days with ≥1 focus session) + const focusDates = [ + ...new Set( + focusSessions + .filter((s) => s.startTime) + .map((s) => format(new Date(s.startTime), "yyyy-MM-dd")), + ), + ].sort(); + + let currentStreak = 0; + if (focusDates.length > 0) { + const today = format(new Date(), "yyyy-MM-dd"); + const yesterday = format(subDays(new Date(), 1), "yyyy-MM-dd"); + const lastDate = focusDates[focusDates.length - 1]; + + if (lastDate === today || lastDate === yesterday) { + currentStreak = 1; + for (let i = focusDates.length - 2; i >= 0; i--) { + const diff = differenceInCalendarDays( + new Date(focusDates[i + 1]), + new Date(focusDates[i]), + ); + if (diff === 1) { + currentStreak += 1; + } else { + break; + } + } + } + } + + // Weekly / monthly totals + const weekAgo = subDays(new Date(), 7); + const thisWeekSessions = focusSessions.filter( + (s) => s.startTime && new Date(s.startTime) >= weekAgo, + ); + const weeklyFocusMin = Math.round( + thisWeekSessions.reduce((sum, s) => sum + (s.duration || 0), 0) / 60, + ); + const monthlyFocusMin = Math.round(totalFocusSeconds / 60); + + // Task stats + const totalTasks = tasks.length; + const completedTasks = tasks.filter((t) => t.isCompleted).length; + const taskCompletionRate = + totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + + // Mood distribution + const moodCounts = {}; + focusSessions.forEach((s) => { + if (s.mood) { + moodCounts[s.mood] = (moodCounts[s.mood] || 0) + 1; + } + }); + + return { + period: { days, since: since.toISOString() }, + focus: { + totalSessions: totalFocusSessions, + totalMinutes: monthlyFocusMin, + avgDurationMin: avgFocusDurationMin, + completionRate, + weeklyMinutes: weeklyFocusMin, + }, + patterns: { + peakHours, + breakFrequency, + currentStreak, + moodDistribution: moodCounts, + }, + tasks: { + total: totalTasks, + completed: completedTasks, + completionRate: taskCompletionRate, + }, + _meta: { + generatedAt: new Date().toISOString(), + sessionCount: sessions.length, + }, + }; +} + +module.exports = { aggregateUserStats }; diff --git a/backend/src/services/analytics/productivityScore.js b/backend/src/services/analytics/productivityScore.js new file mode 100644 index 0000000..d0a7230 --- /dev/null +++ b/backend/src/services/analytics/productivityScore.js @@ -0,0 +1,76 @@ +/** + * Productivity Score Calculator + * + * Computes a 0–100 score from aggregated stats: + * - Consistency (streak): 30% + * - Completion rate: 25% + * - Focus quality (duration): 25% + * - Time management (peak hrs): 20% + * + * @param {Object} stats - Output of aggregateUserStats() + * @param {Object} userSettings - User.settings subdocument + * @returns {{ score: number, breakdown: Object }} + */ +function calculateProductivityScore(stats, userSettings = {}) { + const configuredFocus = userSettings.focusDuration || 25; // minutes + + // Consistency (30%) — streak capped at 30 days + const streakMax = 30; + const streakRaw = Math.min(stats.patterns.currentStreak, streakMax); + const consistencyScore = Math.round((streakRaw / streakMax) * 100); + + // Completion rate (25%) — direct percentage + const completionScore = stats.focus.completionRate; + + // ── Focus quality (25%) — how close avg duration is to configured ─ + // If avg >= configured → 100. If avg is 0 → 0. Linear in between. + const avgMin = stats.focus.avgDurationMin; + const focusQuality = + avgMin >= configuredFocus + ? 100 + : configuredFocus > 0 + ? Math.round((avgMin / configuredFocus) * 100) + : 0; + + // ── Time management (20%) — % of sessions in personal peak hours ── + // We can't re-query sessions here (aggregator already computed peaks), + // so we use a heuristic: if the user has identified peak hours AND + // has been doing sessions, award points based on session volume. + // A more precise version would require the aggregator to also return + // "sessions in peak hours count", which we can add later. + // For now: having ≥3 peak hours identified = good time awareness. + const peakHourCount = (stats.patterns.peakHours || []).length; + const hasEnoughData = stats.focus.totalSessions >= 5; + const timeManagement = hasEnoughData + ? Math.min(Math.round((peakHourCount / 3) * 100), 100) + : 0; + + // Weighted total + const score = Math.round( + consistencyScore * 0.3 + + completionScore * 0.25 + + focusQuality * 0.25 + + timeManagement * 0.2, + ); + + return { + score: Math.min(score, 100), + breakdown: { + consistency: { score: consistencyScore, weight: 30, streak: streakRaw }, + completion: { score: completionScore, weight: 25 }, + focusQuality: { + score: focusQuality, + weight: 25, + avgMin, + targetMin: configuredFocus, + }, + timeManagement: { + score: timeManagement, + weight: 20, + peakHours: stats.patterns.peakHours, + }, + }, + }; +} + +module.exports = { calculateProductivityScore }; diff --git a/frontend/src/components/dashboard/AiInsightsPanel.tsx b/frontend/src/components/dashboard/AiInsightsPanel.tsx new file mode 100644 index 0000000..c726d9a --- /dev/null +++ b/frontend/src/components/dashboard/AiInsightsPanel.tsx @@ -0,0 +1,249 @@ +import { useEffect } from 'react'; +import { motion, type Variants } from 'framer-motion'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAiStore } from '@/store/useAiStore'; +import { + Sparkles, + TrendingUp, + Lightbulb, + RefreshCw, + Zap, + Target, + Clock, + Flame, +} from 'lucide-react'; + +const item: Variants = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0 }, +}; + +function ScoreRing({ score }: { score: number }) { + const radius = 40; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (score / 100) * circumference; + + const getScoreColor = () => { + if (score >= 80) return 'text-emerald-500'; + if (score >= 60) return 'text-blue-500'; + if (score >= 40) return 'text-amber-500'; + return 'text-red-500'; + }; + + const getStrokeColor = () => { + if (score >= 80) return '#10b981'; + if (score >= 60) return '#3b82f6'; + if (score >= 40) return '#f59e0b'; + return '#ef4444'; + }; + + return ( +
+ + + + +
+ {score} + / 100 +
+
+ ); +} + +function ScoreBreakdownBar({ + label, + score, + icon: Icon, +}: { + label: string; + score: number; + icon: React.ElementType; +}) { + const getBarColor = () => { + if (score >= 80) return 'bg-emerald-500'; + if (score >= 60) return 'bg-blue-500'; + if (score >= 40) return 'bg-amber-500'; + return 'bg-red-500'; + }; + + return ( +
+ + {label} +
+
+
+ {score} +
+ ); +} + +export function AiInsightsPanel() { + const { insights, isLoading, error, fetchAiInsights } = useAiStore(); + + useEffect(() => { + fetchAiInsights(); + }, [fetchAiInsights]); + + if (isLoading) { + return ( + + + +
+
+
+
+
+
+
+
+
+
+
+ + + + ); + } + + if (error && !insights) { + return ( + + + + +

AI insights unavailable right now.

+ +
+
+
+ ); + } + + if (!insights) return null; + + const { productivityScore, scoreBreakdown, insights: aiInsights, recommendations, summary } = insights; + + return ( + + + +
+ + + AI Insights + + +
+
+ + +
+ {/* Productivity Score */} +
+ +

Productivity Score

+
+ + + + +
+
+ + {/* Insights */} +
+

+ + Insights +

+ {aiInsights.map((insight, i) => ( +
+ {insight} +
+ ))} +
+ + {/* Recommendations + Summary */} +
+

+ + Recommendations +

+ {recommendations.map((rec, i) => ( +
+ {rec} +
+ ))} + + {summary && ( +
+

✨ Daily Summary

+

{summary}

+
+ )} +
+
+
+
+
+ ); +} From 72d2b3442631a9185486910b9ae9915ac273967e Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:30:10 +0530 Subject: [PATCH 03/30] feat: implement AI insights service, state management, and dashboard integration panel --- frontend/src/components/Dashboard.tsx | 3 ++ frontend/src/services/aiApi.ts | 46 +++++++++++++++++++++++++++ frontend/src/store/useAiStore.ts | 38 ++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 frontend/src/services/aiApi.ts create mode 100644 frontend/src/store/useAiStore.ts diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index c580433..ff5aa9b 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -9,6 +9,7 @@ import { WelcomeHeader } from './dashboard/WelcomeHeader'; import { StatsOverview } from './dashboard/StatsOverview'; import { PriorityTasks } from './dashboard/PriorityTasks'; import { DailyOverviewChart } from './dashboard/DailyOverviewChart'; +import { AiInsightsPanel } from './dashboard/AiInsightsPanel'; const MOTIVATIONAL_QUOTES = [ 'Focus is the gateway to thinking, learning, and memory.', @@ -137,6 +138,8 @@ export function Dashboard() { averageFocusDuration={averageFocusDuration} /> + +
diff --git a/frontend/src/services/aiApi.ts b/frontend/src/services/aiApi.ts new file mode 100644 index 0000000..94d80ec --- /dev/null +++ b/frontend/src/services/aiApi.ts @@ -0,0 +1,46 @@ +import api from './api'; + +export interface AiInsightsResponse { + insights: string[]; + recommendations: string[]; + summary: string; + productivityScore: number; + scoreBreakdown: { + consistency: { score: number; weight: number; streak: number }; + completion: { score: number; weight: number }; + focusQuality: { score: number; weight: number; avgMin: number; targetMin: number }; + timeManagement: { score: number; weight: number; peakHours: number[] }; + }; + stats: { + focus: { + totalSessions: number; + totalMinutes: number; + avgDurationMin: number; + completionRate: number; + weeklyMinutes: number; + }; + patterns: { + peakHours: number[]; + breakFrequency: number; + currentStreak: number; + moodDistribution: Record; + }; + tasks: { + total: number; + completed: number; + completionRate: number; + }; + }; + generatedAt: string; + fromCache: boolean; +} + +export async function fetchInsights(): Promise { + const { data } = await api.get('/ai/insights'); + return data; +} + +export async function fetchAnalyticsSummary(days = 30) { + const { data } = await api.get(`/ai/summary?days=${days}`); + return data; +} diff --git a/frontend/src/store/useAiStore.ts b/frontend/src/store/useAiStore.ts new file mode 100644 index 0000000..e42b433 --- /dev/null +++ b/frontend/src/store/useAiStore.ts @@ -0,0 +1,38 @@ +import { create } from 'zustand'; +import { fetchInsights, type AiInsightsResponse } from '../services/aiApi'; + +interface AiState { + insights: AiInsightsResponse | null; + isLoading: boolean; + error: string | null; + lastFetched: Date | null; + + fetchAiInsights: () => Promise; + clearInsights: () => void; +} + +export const useAiStore = create((set) => ({ + insights: null, + isLoading: false, + error: null, + lastFetched: null, + + fetchAiInsights: async () => { + set({ isLoading: true, error: null }); + try { + const data = await fetchInsights(); + set({ + insights: data, + isLoading: false, + lastFetched: new Date(), + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to load AI insights'; + set({ isLoading: false, error: message }); + } + }, + + clearInsights: () => { + set({ insights: null, error: null, lastFetched: null }); + }, +})); From a0cc3abaae92b836aa90e5b62aad9b2f484c0732 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:33:52 +0530 Subject: [PATCH 04/30] feat: implement AI-driven study planning service and user profile management system --- .../src/controllers/studyProfileController.js | 51 ++++++++++++++++++ backend/src/models/StudyPlan.js | 52 +++++++++++++++++++ backend/src/routes/studyProfileRoutes.js | 22 ++++++++ 3 files changed, 125 insertions(+) create mode 100644 backend/src/controllers/studyProfileController.js create mode 100644 backend/src/models/StudyPlan.js create mode 100644 backend/src/routes/studyProfileRoutes.js diff --git a/backend/src/controllers/studyProfileController.js b/backend/src/controllers/studyProfileController.js new file mode 100644 index 0000000..1553c34 --- /dev/null +++ b/backend/src/controllers/studyProfileController.js @@ -0,0 +1,51 @@ +const asyncHandler = require("express-async-handler"); +const User = require("../models/User"); + +// @desc Get current user's study profile +// @route GET /api/study-profile +// @access Private +const getStudyProfile = asyncHandler(async (req, res) => { + const user = await User.findById(req.user._id).select("studyProfile"); + + res.json({ + studyProfile: user.studyProfile || { + stream: null, + customStreamName: "", + subjects: [], + examDate: null, + weeklyGoalHours: 20, + availableHoursPerDay: 4, + }, + }); +}); + +// @desc Update current user's study profile +// @route PUT /api/study-profile +// @access Private +const updateStudyProfile = asyncHandler(async (req, res) => { + const allowedFields = [ + "stream", + "customStreamName", + "subjects", + "examDate", + "weeklyGoalHours", + "availableHoursPerDay", + ]; + + const updates = {}; + for (const field of allowedFields) { + if (req.body[field] !== undefined) { + updates[`studyProfile.${field}`] = req.body[field]; + } + } + + const user = await User.findByIdAndUpdate( + req.user._id, + { $set: updates }, + { new: true, runValidators: true }, + ).select("studyProfile"); + + res.json({ studyProfile: user.studyProfile }); +}); + +module.exports = { getStudyProfile, updateStudyProfile }; diff --git a/backend/src/models/StudyPlan.js b/backend/src/models/StudyPlan.js new file mode 100644 index 0000000..9bc3806 --- /dev/null +++ b/backend/src/models/StudyPlan.js @@ -0,0 +1,52 @@ +const mongoose = require("mongoose"); + +const dailySubjectSchema = mongoose.Schema( + { + name: { type: String, required: true }, + hours: { type: Number, required: true }, + activity: { type: String, default: "Study" }, + }, + { _id: false }, +); + +const dailyPlanSchema = mongoose.Schema( + { + day: { type: String, required: true }, + subjects: [dailySubjectSchema], + }, + { _id: false }, +); + +const weekPlanSchema = mongoose.Schema( + { + weekNumber: { type: Number, required: true }, + startDate: { type: Date }, + endDate: { type: Date }, + dailyPlans: [dailyPlanSchema], + }, + { _id: false }, +); + +const studyPlanSchema = mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: "User", + index: true, + }, + weeks: [weekPlanSchema], + examDate: { type: Date }, + totalWeeks: { type: Number, default: 0 }, + stream: { type: String }, + subjects: [{ type: String }], + generatedAt: { type: Date, default: Date.now }, + }, + { + timestamps: true, + }, +); + +const StudyPlan = mongoose.model("StudyPlan", studyPlanSchema); + +module.exports = StudyPlan; diff --git a/backend/src/routes/studyProfileRoutes.js b/backend/src/routes/studyProfileRoutes.js new file mode 100644 index 0000000..0ad1c8c --- /dev/null +++ b/backend/src/routes/studyProfileRoutes.js @@ -0,0 +1,22 @@ +const express = require("express"); +const { protect } = require("../middleware/authMiddleware"); +const { apiLimiter } = require("../middleware/rateLimitMiddleware"); +const { validate } = require("../middleware/validateMiddleware"); +const { studyProfileBodySchema } = require("../validation/schemas"); +const { + getStudyProfile, + updateStudyProfile, +} = require("../controllers/studyProfileController"); + +const router = express.Router(); + +router.get("/", protect, apiLimiter, getStudyProfile); +router.put( + "/", + protect, + apiLimiter, + validate({ body: studyProfileBodySchema }), + updateStudyProfile, +); + +module.exports = router; From ffbc71836a1a86cf4f57b98ce4d6f51dcc6398ea Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:34:05 +0530 Subject: [PATCH 05/30] feat: implement AI-driven study planning service and user profile schema for personalized academic scheduling --- backend/src/app.js | 2 ++ backend/src/controllers/aiController.js | 43 +++++++++++++++++++++++++ backend/src/models/User.js | 21 ++++++++++++ 3 files changed, 66 insertions(+) diff --git a/backend/src/app.js b/backend/src/app.js index 411d66b..236943c 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -18,6 +18,7 @@ const feedbackRoutes = require("./routes/feedbackRoutes"); const gdprRoutes = require("./routes/gdprRoutes"); const cronRoutes = require("./routes/cronRoutes"); const aiRoutes = require("./routes/aiRoutes"); +const studyProfileRoutes = require("./routes/studyProfileRoutes"); const app = express(); app.set("trust proxy", 1); @@ -89,6 +90,7 @@ app.use("/api/admin", require("./admin/routes/adminRoutes")); // node-cron handles the same job on traditional/local servers (see server.js). app.use("/api/cron", cronRoutes); app.use("/api/ai", aiRoutes); +app.use("/api/study-profile", studyProfileRoutes); app.get(["/favicon.ico", "/favicon.png"], (req, res) => res.status(204).end()); diff --git a/backend/src/controllers/aiController.js b/backend/src/controllers/aiController.js index 75771f8..8a3acc6 100644 --- a/backend/src/controllers/aiController.js +++ b/backend/src/controllers/aiController.js @@ -4,6 +4,8 @@ const { calculateProductivityScore, } = require("../services/analytics/productivityScore"); const { generateInsights } = require("../services/ai/insightEngine"); +const { generateStudyPlan } = require("../services/ai/studyPlanner"); +const { getRecommendations } = require("../services/ai/recommender"); // @desc Get aggregated analytics summary + productivity score // @route GET /api/ai/summary @@ -28,7 +30,48 @@ const getInsights = asyncHandler(async (req, res) => { res.json(result); }); +// @desc Get or generate AI study plan +// @route GET /api/ai/study-plan +// @access Private +const getStudyPlan = asyncHandler(async (req, res) => { + const result = await generateStudyPlan(req.user._id, false); + + if (result.error) { + res.status(result.plan ? 200 : 400).json(result); + return; + } + + res.json(result); +}); + +// @desc Regenerate AI study plan +// @route POST /api/ai/study-plan +// @access Private +const regenerateStudyPlan = asyncHandler(async (req, res) => { + const result = await generateStudyPlan(req.user._id, true); + + if (result.error) { + res.status(result.plan ? 200 : 400).json(result); + return; + } + + res.json(result); +}); + +// @desc Get rule-based recommendations (no LLM) +// @route GET /api/ai/recommendations +// @access Private +const getRecommendationsHandler = asyncHandler(async (req, res) => { + const stats = await aggregateUserStats(req.user._id, 30); + const result = getRecommendations(stats, req.user.settings || {}); + res.json(result); +}); + module.exports = { getAnalyticsSummary, getInsights, + getStudyPlan, + regenerateStudyPlan, + getRecommendationsHandler, }; + diff --git a/backend/src/models/User.js b/backend/src/models/User.js index b87f8cd..ce75585 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -78,6 +78,27 @@ const userSchema = mongoose.Schema( type: String, select: false, }, + studyProfile: { + stream: { + type: String, + enum: ["engineering", "medical", "commerce", "competitive", "custom"], + default: null, + }, + customStreamName: { type: String, default: "" }, + subjects: [ + { + name: { type: String, required: true }, + difficulty: { + type: String, + enum: ["easy", "medium", "hard"], + default: "medium", + }, + }, + ], + examDate: { type: Date, default: null }, + weeklyGoalHours: { type: Number, default: 20 }, + availableHoursPerDay: { type: Number, default: 4 }, + }, }, { timestamps: true, From 8fcd46da1ef0b56a3a7e7b290ff26f82fe72eede Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:34:33 +0530 Subject: [PATCH 06/30] feat: implement AI-driven study planner service and integrate backend endpoints with frontend store --- backend/src/routes/aiRoutes.js | 7 + backend/src/services/ai/recommender.js | 184 ++++++++++++++++++++++++ backend/src/services/ai/studyPlanner.js | 182 +++++++++++++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 backend/src/services/ai/recommender.js create mode 100644 backend/src/services/ai/studyPlanner.js diff --git a/backend/src/routes/aiRoutes.js b/backend/src/routes/aiRoutes.js index 23d36c4..c22d8ab 100644 --- a/backend/src/routes/aiRoutes.js +++ b/backend/src/routes/aiRoutes.js @@ -4,6 +4,9 @@ const { apiLimiter } = require("../middleware/rateLimitMiddleware"); const { getAnalyticsSummary, getInsights, + getStudyPlan, + regenerateStudyPlan, + getRecommendationsHandler, } = require("../controllers/aiController"); const router = express.Router(); @@ -11,5 +14,9 @@ const router = express.Router(); // All AI routes require authentication + rate limiting router.get("/summary", protect, apiLimiter, getAnalyticsSummary); router.get("/insights", protect, apiLimiter, getInsights); +router.get("/study-plan", protect, apiLimiter, getStudyPlan); +router.post("/study-plan", protect, apiLimiter, regenerateStudyPlan); +router.get("/recommendations", protect, apiLimiter, getRecommendationsHandler); module.exports = router; + diff --git a/backend/src/services/ai/recommender.js b/backend/src/services/ai/recommender.js new file mode 100644 index 0000000..bf84b3d --- /dev/null +++ b/backend/src/services/ai/recommender.js @@ -0,0 +1,184 @@ +/** + * Rule-Based Recommender + * + * Generates 2–3 actionable nudges per day from aggregated stats. + * No LLM call needed — pure threshold/rule logic. + * + * Each rule checks a condition and returns a recommendation object + * with { type, message, priority }. + */ + +const RULES = [ + // ── Focus Duration ──────────────────────────────────────────── + { + id: "shorter-sessions", + check: (stats) => + stats.focus.completionRate < 70 && stats.focus.avgDurationMin > 35, + result: () => ({ + type: "focus", + message: + "Your completion rate drops with longer sessions — try 25–30 minute Pomodoros instead.", + priority: "high", + }), + }, + { + id: "longer-sessions", + check: (stats) => + stats.focus.completionRate > 90 && stats.focus.avgDurationMin < 30, + result: (stats) => ({ + type: "focus", + message: `You complete ${stats.focus.completionRate}% of sessions — you might handle 35–40 minute sessions well.`, + priority: "medium", + }), + }, + + // ── Consistency ─────────────────────────────────────────────── + { + id: "streak-at-risk", + check: (stats) => + stats.patterns.currentStreak > 0 && stats.patterns.currentStreak <= 2, + result: (stats) => ({ + type: "streak", + message: `Your ${stats.patterns.currentStreak}-day streak is just getting started — keep it alive today!`, + priority: "high", + }), + }, + { + id: "streak-momentum", + check: (stats) => stats.patterns.currentStreak >= 7, + result: (stats) => ({ + type: "streak", + message: `${stats.patterns.currentStreak}-day streak! You've built solid momentum — don't break the chain.`, + priority: "low", + }), + }, + { + id: "no-streak", + check: (stats) => + stats.patterns.currentStreak === 0 && stats.focus.totalSessions > 0, + result: () => ({ + type: "streak", + message: + "Start a new streak today — even one short session counts!", + priority: "medium", + }), + }, + + // ── Peak Hours ──────────────────────────────────────────────── + { + id: "peak-hours-reminder", + check: (stats) => { + const peaks = stats.patterns.peakHours || []; + if (peaks.length === 0) return false; + const currentHour = new Date().getHours(); + // Suggest if any peak hour is within the next 2 hours + return peaks.some((h) => h >= currentHour && h <= currentHour + 2); + }, + result: (stats) => { + const peakFormatted = (stats.patterns.peakHours || []) + .map((h) => { + const h12 = h % 12 || 12; + return `${h12} ${h < 12 ? "AM" : "PM"}`; + }) + .join(", "); + return { + type: "timing", + message: `Your peak focus time is coming up (${peakFormatted}) — schedule a session now.`, + priority: "high", + }; + }, + }, + + // ── Break Frequency ─────────────────────────────────────────── + { + id: "too-few-breaks", + check: (stats) => + stats.patterns.breakFrequency < 0.5 && stats.focus.totalSessions >= 5, + result: () => ({ + type: "wellness", + message: + "You're not taking enough breaks — regular breaks boost long-term focus.", + priority: "medium", + }), + }, + + // ── Task Completion ─────────────────────────────────────────── + { + id: "low-task-completion", + check: (stats) => + stats.tasks.total > 3 && stats.tasks.completionRate < 50, + result: () => ({ + type: "tasks", + message: + "Less than half your tasks are done — break large tasks into smaller, completable pieces.", + priority: "medium", + }), + }, + + // ── Weekly Volume ───────────────────────────────────────────── + { + id: "low-weekly-volume", + check: (stats) => + stats.focus.weeklyMinutes < 60 && stats.focus.totalSessions > 0, + result: () => ({ + type: "volume", + message: + "Under 1 hour of focus this week — aim for at least 2–3 sessions today.", + priority: "high", + }), + }, + { + id: "great-weekly-volume", + check: (stats) => stats.focus.weeklyMinutes > 600, + result: (stats) => ({ + type: "volume", + message: `${Math.round(stats.focus.weeklyMinutes / 60)} hours of focus this week — excellent! Make sure to rest too.`, + priority: "low", + }), + }, +]; + +/** + * Get personalized recommendations for a user. + * + * @param {Object} stats - Output from aggregateUserStats() + * @param {Object} [userSettings={}] - User.settings + * @returns {{ recommendations: Array<{ type: string, message: string, priority: string }> }} + */ +function getRecommendations(stats, userSettings = {}) { + if (!stats || stats.focus.totalSessions === 0) { + return { + recommendations: [ + { + type: "onboarding", + message: + "Complete your first focus session to get personalized recommendations!", + priority: "medium", + }, + ], + }; + } + + const triggered = []; + + for (const rule of RULES) { + try { + if (rule.check(stats, userSettings)) { + triggered.push({ id: rule.id, ...rule.result(stats, userSettings) }); + } + } catch { + // Skip broken rules silently + } + } + + // Sort by priority and return top 3 + const priorityOrder = { high: 0, medium: 1, low: 2 }; + triggered.sort( + (a, b) => + (priorityOrder[a.priority] ?? 1) - (priorityOrder[b.priority] ?? 1), + ); + + return { recommendations: triggered.slice(0, 3) }; +} + +module.exports = { getRecommendations, RULES }; diff --git a/backend/src/services/ai/studyPlanner.js b/backend/src/services/ai/studyPlanner.js new file mode 100644 index 0000000..f329175 --- /dev/null +++ b/backend/src/services/ai/studyPlanner.js @@ -0,0 +1,182 @@ +const User = require("../../models/User"); +const StudyPlan = require("../../models/StudyPlan"); +const { aggregateUserStats } = require("../analytics/aggregator"); +const { generate } = require("../llmService"); +const { differenceInWeeks, addWeeks, format } = require("date-fns"); + +/** + * Build a compact prompt for study plan generation. + */ +function buildPlannerPrompt(profile, stats) { + const subjectList = (profile.subjects || []) + .map((s) => `${s.name} (${s.difficulty || "medium"})`) + .join(", "); + + const peakHoursFormatted = (stats.patterns.peakHours || []) + .map((h) => { + const hour12 = h % 12 || 12; + const ampm = h < 12 ? "AM" : "PM"; + return `${hour12} ${ampm}`; + }) + .join(", "); + + const examDate = profile.examDate + ? format(new Date(profile.examDate), "yyyy-MM-dd") + : "not set"; + + const weeksUntilExam = profile.examDate + ? Math.max(differenceInWeeks(new Date(profile.examDate), new Date()), 1) + : 4; + + return `You are a study planner for a ${profile.stream || "general"} student. + +Student Profile: +- Stream: ${profile.stream || "general"}${profile.customStreamName ? ` (${profile.customStreamName})` : ""} +- Subjects: ${subjectList || "not specified"} +- Exam date: ${examDate} +- Weeks until exam: ${weeksUntilExam} +- Available hours/day: ${profile.availableHoursPerDay || 4} +- Weekly goal: ${profile.weeklyGoalHours || 20} hours + +Productivity Data: +- Best study hours: ${peakHoursFormatted || "not enough data"} +- Average focus duration: ${stats.focus.avgDurationMin} minutes +- Session completion rate: ${stats.focus.completionRate}% + +Generate a study plan for ${Math.min(weeksUntilExam, 8)} weeks as JSON (no markdown fences): +{ + "weeks": [ + { + "weekNumber": 1, + "theme": "Foundation concepts", + "dailyPlans": [ + { + "day": "Monday", + "subjects": [ + { "name": "Subject Name", "hours": 2, "activity": "Study" } + ] + } + ] + } + ] +} + +Rules: +- Spread subjects across the week, harder subjects during peak hours +- Include revision and practice days +- Total daily hours must not exceed ${profile.availableHoursPerDay || 4} +- Last 1-2 weeks should focus on revision and mock tests +- Activity types: Study, Revision, Practice, Mock Test +- Include all 7 days (Monday to Sunday) with lighter loads on weekends +- Keep it realistic and achievable`; +} + +/** + * Parse the LLM study plan response. + */ +function parsePlanResponse(text, weeksCount) { + try { + let cleaned = text.trim(); + if (cleaned.startsWith("```")) { + cleaned = cleaned.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, ""); + } + const parsed = JSON.parse(cleaned); + + if (Array.isArray(parsed.weeks) && parsed.weeks.length > 0) { + return parsed.weeks.slice(0, weeksCount); + } + return null; + } catch { + return null; + } +} + +/** + * Generate or retrieve a study plan for a user. + * + * @param {string} userId + * @param {boolean} [forceRegenerate=false] - If true, regenerate even if a plan exists + */ +async function generateStudyPlan(userId, forceRegenerate = false) { + // Check for existing plan if not forcing regeneration + if (!forceRegenerate) { + const existing = await StudyPlan.findOne({ user: userId }) + .sort({ generatedAt: -1 }) + .lean(); + + if (existing) { + return { plan: existing, fromCache: true }; + } + } + + // Fetch user profile and stats + const user = await User.findById(userId).select("studyProfile settings"); + const profile = user.studyProfile || {}; + + if (!profile.stream && (!profile.subjects || profile.subjects.length === 0)) { + return { + plan: null, + error: "Please set up your study profile first (stream and subjects).", + }; + } + + const stats = await aggregateUserStats(userId, 30); + + const weeksUntilExam = profile.examDate + ? Math.max(differenceInWeeks(new Date(profile.examDate), new Date()), 1) + : 4; + const planWeeks = Math.min(weeksUntilExam, 8); + + // Call LLM + let weeks; + try { + const prompt = buildPlannerPrompt(profile, stats); + const llmResponse = await generate(prompt, null, { + max_tokens: 2000, + temperature: 0.3, + }); + weeks = parsePlanResponse(llmResponse, planWeeks); + } catch (err) { + // If LLM fails, return existing plan or error + const fallback = await StudyPlan.findOne({ user: userId }) + .sort({ generatedAt: -1 }) + .lean(); + + if (fallback) { + return { plan: fallback, fromCache: true, stale: true }; + } + return { plan: null, error: "Failed to generate study plan. Please try again later." }; + } + + if (!weeks) { + return { plan: null, error: "Could not parse AI response. Please try again." }; + } + + // Add dates to weeks + const now = new Date(); + const weeksWithDates = weeks.map((week, i) => ({ + ...week, + weekNumber: i + 1, + startDate: addWeeks(now, i), + endDate: addWeeks(now, i + 1), + })); + + // Save plan (replace existing) + const saved = await StudyPlan.findOneAndUpdate( + { user: userId }, + { + user: userId, + weeks: weeksWithDates, + examDate: profile.examDate, + totalWeeks: planWeeks, + stream: profile.stream, + subjects: (profile.subjects || []).map((s) => s.name), + generatedAt: new Date(), + }, + { upsert: true, new: true }, + ); + + return { plan: saved, fromCache: false }; +} + +module.exports = { generateStudyPlan, buildPlannerPrompt, parsePlanResponse }; From 96ef52b73dfae9453c82abee92c3a78476225a66 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:34:59 +0530 Subject: [PATCH 07/30] feat: implement study profile management with UI component and validation schema --- .../settings/StudyProfileSettings.tsx | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 frontend/src/components/settings/StudyProfileSettings.tsx diff --git a/frontend/src/components/settings/StudyProfileSettings.tsx b/frontend/src/components/settings/StudyProfileSettings.tsx new file mode 100644 index 0000000..2d38543 --- /dev/null +++ b/frontend/src/components/settings/StudyProfileSettings.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useAiStore } from '@/store/useAiStore'; +import { toast } from 'sonner'; +import { GraduationCap, Plus, X, Save, Loader2 } from 'lucide-react'; +import type { StudySubject } from '@/services/aiApi'; + +const STREAMS = [ + { value: 'engineering', label: 'Engineering' }, + { value: 'medical', label: 'Medical' }, + { value: 'commerce', label: 'Commerce' }, + { value: 'competitive', label: 'Competitive Exams' }, + { value: 'custom', label: 'Custom' }, +]; + +export function StudyProfileSettings() { + const { studyProfile, profileLoading, fetchStudyProfile, updateStudyProfile } = useAiStore(); + + const [stream, setStream] = useState(''); + const [customStreamName, setCustomStreamName] = useState(''); + const [subjects, setSubjects] = useState([]); + const [examDate, setExamDate] = useState(''); + const [weeklyGoalHours, setWeeklyGoalHours] = useState(20); + const [availableHoursPerDay, setAvailableHoursPerDay] = useState(4); + const [newSubjectName, setNewSubjectName] = useState(''); + + useEffect(() => { + fetchStudyProfile(); + }, [fetchStudyProfile]); + + useEffect(() => { + if (studyProfile) { + setStream(studyProfile.stream || ''); + setCustomStreamName(studyProfile.customStreamName || ''); + setSubjects(studyProfile.subjects || []); + setExamDate(studyProfile.examDate ? studyProfile.examDate.split('T')[0] : ''); + setWeeklyGoalHours(studyProfile.weeklyGoalHours || 20); + setAvailableHoursPerDay(studyProfile.availableHoursPerDay || 4); + } + }, [studyProfile]); + + const addSubject = () => { + if (!newSubjectName.trim()) return; + if (subjects.length >= 20) { + toast.error('Maximum 20 subjects allowed'); + return; + } + setSubjects([...subjects, { name: newSubjectName.trim(), difficulty: 'medium' }]); + setNewSubjectName(''); + }; + + const removeSubject = (index: number) => { + setSubjects(subjects.filter((_, i) => i !== index)); + }; + + const updateSubjectDifficulty = (index: number, difficulty: 'easy' | 'medium' | 'hard') => { + const updated = [...subjects]; + updated[index] = { ...updated[index], difficulty }; + setSubjects(updated); + }; + + const handleSave = async () => { + await updateStudyProfile({ + stream: stream || undefined, + customStreamName: stream === 'custom' ? customStreamName : '', + subjects, + examDate: examDate || null, + weeklyGoalHours, + availableHoursPerDay, + }); + toast.success('Study profile saved'); + }; + + const difficultyColors: Record = { + easy: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20', + medium: 'bg-amber-500/10 text-amber-500 border-amber-500/20', + hard: 'bg-red-500/10 text-red-500 border-red-500/20', + }; + + return ( + + + + + Study Profile + +

+ Set up your study profile to get AI-powered study plans and recommendations. +

+
+ + {/* Stream */} +
+ +
+ {STREAMS.map((s) => ( + + ))} +
+ {stream === 'custom' && ( + setCustomStreamName(e.target.value)} + className="mt-2" + /> + )} +
+ + {/* Subjects */} +
+ +
+ setNewSubjectName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addSubject()} + /> + +
+
+ {subjects.map((subject, i) => ( +
+ {subject.name} + + +
+ ))} +
+
+ + {/* Exam Date */} +
+
+ + setExamDate(e.target.value)} /> +
+
+ + setWeeklyGoalHours(Number(e.target.value))} + /> +
+
+ + setAvailableHoursPerDay(Number(e.target.value))} + /> +
+
+ + {/* Save */} + +
+
+ ); +} From 40ded33e5ce39241354950a5f83f06f5f86fe015 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:52:59 +0530 Subject: [PATCH 08/30] feat: implement adaptive study planning and focus analytics features --- .../dashboard/RecommendationsCard.tsx | 88 ++++++++++++ .../pomodoro/AdaptiveTimerSuggestion.tsx | 71 ++++++++++ frontend/src/services/aiApi.ts | 97 ++++++++++++++ frontend/src/store/useAiStore.ts | 125 +++++++++++++++++- 4 files changed, 374 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/dashboard/RecommendationsCard.tsx create mode 100644 frontend/src/components/pomodoro/AdaptiveTimerSuggestion.tsx diff --git a/frontend/src/components/dashboard/RecommendationsCard.tsx b/frontend/src/components/dashboard/RecommendationsCard.tsx new file mode 100644 index 0000000..063f080 --- /dev/null +++ b/frontend/src/components/dashboard/RecommendationsCard.tsx @@ -0,0 +1,88 @@ +import { useEffect } from 'react'; +import { motion, type Variants } from 'framer-motion'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAiStore } from '@/store/useAiStore'; +import { + Zap, + Flame, + Clock, + Target, + TrendingUp, + Heart, + BarChart3, + AlertCircle, +} from 'lucide-react'; + +const item: Variants = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0 }, +}; + +const typeIcons: Record = { + focus: Zap, + streak: Flame, + timing: Clock, + tasks: Target, + volume: BarChart3, + wellness: Heart, + onboarding: AlertCircle, +}; + +const priorityStyles: Record = { + high: 'border-l-amber-500 bg-amber-500/5', + medium: 'border-l-blue-500 bg-blue-500/5', + low: 'border-l-emerald-500 bg-emerald-500/5', +}; + +export function RecommendationsCard() { + const { recommendations, recsLoading, fetchRecommendations } = useAiStore(); + + useEffect(() => { + fetchRecommendations(); + }, [fetchRecommendations]); + + if (recsLoading && recommendations.length === 0) { + return ( + + + +
+
+
+
+
+ + + + ); + } + + if (recommendations.length === 0) return null; + + return ( + + + + + + Smart Nudges + + + + {recommendations.map((rec, i) => { + const Icon = typeIcons[rec.type] || Zap; + return ( +
+ +

{rec.message}

+
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/pomodoro/AdaptiveTimerSuggestion.tsx b/frontend/src/components/pomodoro/AdaptiveTimerSuggestion.tsx new file mode 100644 index 0000000..36038c0 --- /dev/null +++ b/frontend/src/components/pomodoro/AdaptiveTimerSuggestion.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Sparkles, X, Check } from 'lucide-react'; +import { useAiStore } from '@/store/useAiStore'; + +interface AdaptiveTimerSuggestionProps { + onApply: (focus: number, shortBreak: number, longBreak: number) => void; +} + +export function AdaptiveTimerSuggestion({ onApply }: AdaptiveTimerSuggestionProps) { + const { adaptiveSuggestion, adaptiveLoading, fetchAdaptiveTimer } = useAiStore(); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + fetchAdaptiveTimer(); + }, [fetchAdaptiveTimer]); + + if (adaptiveLoading || !adaptiveSuggestion || !adaptiveSuggestion.hasEnoughData || dismissed) { + return null; + } + + const { suggestedFocusDuration, suggestedShortBreak, suggestedLongBreak } = adaptiveSuggestion; + + // Don't show if we don't have suggestions (shouldn't happen if hasEnoughData is true, but just in case) + if (!suggestedFocusDuration || !suggestedShortBreak || !suggestedLongBreak) { + return null; + } + + return ( + + +
+
+
+ +
+
+

AI Timer Suggestion

+

+ Based on your history, a {suggestedFocusDuration}-min focus works best for you. +

+
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/services/aiApi.ts b/frontend/src/services/aiApi.ts index 94d80ec..40369b9 100644 --- a/frontend/src/services/aiApi.ts +++ b/frontend/src/services/aiApi.ts @@ -35,6 +35,74 @@ export interface AiInsightsResponse { fromCache: boolean; } +export interface StudySubject { + name: string; + difficulty?: 'easy' | 'medium' | 'hard'; +} + +export interface StudyProfile { + stream: string | null; + customStreamName: string; + subjects: StudySubject[]; + examDate: string | null; + weeklyGoalHours: number; + availableHoursPerDay: number; +} + +export interface DailySubject { + name: string; + hours: number; + activity: string; +} + +export interface DailyPlan { + day: string; + subjects: DailySubject[]; +} + +export interface WeekPlan { + weekNumber: number; + startDate: string; + endDate: string; + theme: string; + dailyPlans: DailyPlan[]; +} + +export interface StudyPlanResponse { + plan: { + _id: string; + weeks: WeekPlan[]; + examDate: string; + totalWeeks: number; + stream: string; + subjects: string[]; + generatedAt: string; + } | null; + fromCache?: boolean; + error?: string; +} + +export interface Recommendation { + id?: string; + type: string; + message: string; + priority: 'high' | 'medium' | 'low'; +} + +export interface RecommendationsResponse { + recommendations: Recommendation[]; +} + +export interface AdaptiveTimerSuggestion { + hasEnoughData: boolean; + message?: string; + suggestedFocusDuration?: number; + suggestedShortBreak?: number; + suggestedLongBreak?: number; + confidence?: 'low' | 'medium' | 'high'; +} + +// ── AI Insights ───────────────────────────────────────────────── export async function fetchInsights(): Promise { const { data } = await api.get('/ai/insights'); return data; @@ -44,3 +112,32 @@ export async function fetchAnalyticsSummary(days = 30) { const { data } = await api.get(`/ai/summary?days=${days}`); return data; } + +// ── Study Profile ─────────────────────────────────────────────── +export async function fetchStudyProfile(): Promise<{ studyProfile: StudyProfile }> { + const { data } = await api.get('/study-profile'); + return data; +} + +export async function updateStudyProfile(profile: Partial): Promise<{ studyProfile: StudyProfile }> { + const { data } = await api.put('/study-profile', profile); + return data; +} + +// ── Study Plan ────────────────────────────────────────────────── +export async function fetchStudyPlan(): Promise { + const { data } = await api.get('/ai/study-plan'); + return data; +} + +export async function regenerateStudyPlan(): Promise { + const { data } = await api.post('/ai/study-plan'); + return data; +} + +// ── Adaptive Timer ────────────────────────────────────────────── +export async function fetchAdaptiveTimer(): Promise { + const { data } = await api.get('/ai/adaptive-timer'); + return data; +} + diff --git a/frontend/src/store/useAiStore.ts b/frontend/src/store/useAiStore.ts index e42b433..163851c 100644 --- a/frontend/src/store/useAiStore.ts +++ b/frontend/src/store/useAiStore.ts @@ -1,17 +1,54 @@ import { create } from 'zustand'; -import { fetchInsights, type AiInsightsResponse } from '../services/aiApi'; +import { + fetchInsights, + fetchStudyProfile, + updateStudyProfile as updateStudyProfileApi, + fetchStudyPlan, + regenerateStudyPlan as regenerateStudyPlanApi, + fetchRecommendations, + fetchAdaptiveTimer, + type AiInsightsResponse, + type StudyProfile, + type StudyPlanResponse, + type Recommendation, + type AdaptiveTimerSuggestion, +} from '../services/aiApi'; interface AiState { + // Insights insights: AiInsightsResponse | null; isLoading: boolean; error: string | null; lastFetched: Date | null; - fetchAiInsights: () => Promise; clearInsights: () => void; + + // Study Profile + studyProfile: StudyProfile | null; + profileLoading: boolean; + fetchStudyProfile: () => Promise; + updateStudyProfile: (profile: Partial) => Promise; + + // Study Plan + studyPlan: StudyPlanResponse | null; + planLoading: boolean; + planError: string | null; + fetchStudyPlan: () => Promise; + regenerateStudyPlan: () => Promise; + + // Recommendations + recommendations: Recommendation[]; + recsLoading: boolean; + fetchRecommendations: () => Promise; + + // Adaptive Timer + adaptiveSuggestion: AdaptiveTimerSuggestion | null; + adaptiveLoading: boolean; + fetchAdaptiveTimer: () => Promise; } export const useAiStore = create((set) => ({ + // ── Insights ──────────────────────────────────────────────────── insights: null, isLoading: false, error: null, @@ -21,11 +58,7 @@ export const useAiStore = create((set) => ({ set({ isLoading: true, error: null }); try { const data = await fetchInsights(); - set({ - insights: data, - isLoading: false, - lastFetched: new Date(), - }); + set({ insights: data, isLoading: false, lastFetched: new Date() }); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to load AI insights'; set({ isLoading: false, error: message }); @@ -35,4 +68,82 @@ export const useAiStore = create((set) => ({ clearInsights: () => { set({ insights: null, error: null, lastFetched: null }); }, + + // ── Study Profile ────────────────────────────────────────────── + studyProfile: null, + profileLoading: false, + + fetchStudyProfile: async () => { + set({ profileLoading: true }); + try { + const { studyProfile } = await fetchStudyProfile(); + set({ studyProfile, profileLoading: false }); + } catch { + set({ profileLoading: false }); + } + }, + + updateStudyProfile: async (profile) => { + set({ profileLoading: true }); + try { + const { studyProfile } = await updateStudyProfileApi(profile); + set({ studyProfile, profileLoading: false }); + } catch { + set({ profileLoading: false }); + } + }, + + // ── Study Plan ───────────────────────────────────────────────── + studyPlan: null, + planLoading: false, + planError: null, + + fetchStudyPlan: async () => { + set({ planLoading: true, planError: null }); + try { + const result = await fetchStudyPlan(); + set({ studyPlan: result, planLoading: false, planError: result.error || null }); + } catch { + set({ planLoading: false, planError: 'Failed to load study plan' }); + } + }, + + regenerateStudyPlan: async () => { + set({ planLoading: true, planError: null }); + try { + const result = await regenerateStudyPlanApi(); + set({ studyPlan: result, planLoading: false, planError: result.error || null }); + } catch { + set({ planLoading: false, planError: 'Failed to generate study plan' }); + } + }, + + // ── Recommendations ──────────────────────────────────────────── + recommendations: [], + recsLoading: false, + + fetchRecommendations: async () => { + set({ recsLoading: true }); + try { + const { recommendations } = await fetchRecommendations(); + set({ recommendations, recsLoading: false }); + } catch { + set({ recsLoading: false }); + } + }, + + // ── Adaptive Timer ───────────────────────────────────────────── + adaptiveSuggestion: null, + adaptiveLoading: false, + + fetchAdaptiveTimer: async () => { + set({ adaptiveLoading: true }); + try { + const suggestion = await fetchAdaptiveTimer(); + set({ adaptiveSuggestion: suggestion, adaptiveLoading: false }); + } catch { + set({ adaptiveLoading: false }); + } + }, })); + From 67c795b366b9d88ddcc1d9dc4af810e31a31b5db Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:53:17 +0530 Subject: [PATCH 09/30] feat: implement RAG pipeline with document --- frontend/src/components/Dashboard.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index ff5aa9b..149ea7c 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { FocusHeatmap } from './FocusHeatmap'; import { motion, type Variants } from 'framer-motion'; import { useTaskStore } from '@/store/useTaskStore'; @@ -10,6 +10,9 @@ import { StatsOverview } from './dashboard/StatsOverview'; import { PriorityTasks } from './dashboard/PriorityTasks'; import { DailyOverviewChart } from './dashboard/DailyOverviewChart'; import { AiInsightsPanel } from './dashboard/AiInsightsPanel'; +import { StudyPlanCard } from './dashboard/StudyPlanCard'; +import { RecommendationsCard } from './dashboard/RecommendationsCard'; +import { useAiStore } from '@/store/useAiStore'; const MOTIVATIONAL_QUOTES = [ 'Focus is the gateway to thinking, learning, and memory.', @@ -25,6 +28,11 @@ export function Dashboard() { const { tasks } = useTaskStore(); const { settings } = useSettingsStore(); const { sessions } = useHistoryStore(); + const { fetchStudyProfile: loadProfile } = useAiStore(); + + useEffect(() => { + loadProfile(); + }, [loadProfile]); const points = useMemo(() => { const sessionPoints = sessions.filter((s) => s.type === 'pomodoro').length * 25; @@ -140,6 +148,11 @@ export function Dashboard() { +
+ +
+ +
From aa55314f552da58f96146554ec5355b346617ccf Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:53:32 +0530 Subject: [PATCH 10/30] feat: implement RAG-based document querying, quiz generation, and associated study assistant UI components --- frontend/src/components/Settings.tsx | 8 +- .../components/dashboard/StudyPlanCard.tsx | 145 ++++++++++++++ .../src/components/pomodoro/PomodoroTimer.tsx | 34 +++- .../src/components/study/QuizGenerator.tsx | 186 ++++++++++++++++++ .../src/components/study/StudyAssistant.tsx | 100 ++++++++++ 5 files changed, 464 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/dashboard/StudyPlanCard.tsx create mode 100644 frontend/src/components/study/QuizGenerator.tsx create mode 100644 frontend/src/components/study/StudyAssistant.tsx diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 470c4a2..e48f3d4 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -1,4 +1,4 @@ -import { Check, UserCog, Timer, Palette, Zap, Monitor } from 'lucide-react'; +import { Check, UserCog, Timer, Palette, Zap, Monitor, GraduationCap } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import { motion } from 'framer-motion'; @@ -10,10 +10,12 @@ import { AppearanceSettings } from './settings/AppearanceSettings'; import { AutomationSettings } from './settings/AutomationSettings'; import { SystemSettings } from './settings/SystemSettings'; import { AccountSettings } from './settings/AccountSettings'; +import { StudyProfileSettings } from './settings/StudyProfileSettings'; export function Settings() { const tabs = [ { id: 'account', label: 'Account', icon: UserCog }, + { id: 'study', label: 'Study', icon: GraduationCap }, { id: 'timer', label: 'Timer', icon: Timer }, { id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'automation', label: 'Automation', icon: Zap }, @@ -66,6 +68,10 @@ export function Settings() { + + + + diff --git a/frontend/src/components/dashboard/StudyPlanCard.tsx b/frontend/src/components/dashboard/StudyPlanCard.tsx new file mode 100644 index 0000000..91c4b7e --- /dev/null +++ b/frontend/src/components/dashboard/StudyPlanCard.tsx @@ -0,0 +1,145 @@ +import { useEffect } from 'react'; +import { motion, type Variants } from 'framer-motion'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useAiStore } from '@/store/useAiStore'; +import { BookOpen, RefreshCw, Calendar, Loader2, Settings } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +const item: Variants = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0 }, +}; + +const activityColors: Record = { + Study: 'bg-blue-500/10 text-blue-500', + Revision: 'bg-amber-500/10 text-amber-500', + Practice: 'bg-emerald-500/10 text-emerald-500', + 'Mock Test': 'bg-purple-500/10 text-purple-500', +}; + +export function StudyPlanCard() { + const { studyPlan, planLoading, planError, fetchStudyPlan, regenerateStudyPlan, studyProfile } = + useAiStore(); + const navigate = useNavigate(); + + useEffect(() => { + fetchStudyPlan(); + }, [fetchStudyPlan]); + + const plan = studyPlan?.plan; + + // Get the current week's plan (week 1 or the first available) + const currentWeek = plan?.weeks?.[0]; + + // No study profile set up + if (!studyProfile?.stream && !studyProfile?.subjects?.length) { + return ( + + + + +

+ Set up your study profile to get an AI-powered study plan. +

+ +
+
+
+ ); + } + + return ( + + + +
+ + + Study Plan + + +
+
+ + + {planLoading && !plan && ( +
+ + Generating your study plan... +
+ )} + + {planError && !plan && ( +

{planError}

+ )} + + {currentWeek && ( +
+ {plan?.examDate && ( +
+ + Exam: {new Date(plan.examDate).toLocaleDateString()} + {currentWeek.theme && ( + + Week {currentWeek.weekNumber}: {currentWeek.theme} + + )} +
+ )} + + {currentWeek.dailyPlans?.slice(0, 5).map((dayPlan, i) => ( +
+ + {dayPlan.day?.slice(0, 3)} + +
+ {dayPlan.subjects?.map((subject, j) => ( + + {subject.name} · {subject.hours}h + + ))} +
+
+ ))} + + {plan && plan.weeks.length > 1 && ( +

+ {plan.totalWeeks} weeks planned · Showing week {currentWeek.weekNumber} +

+ )} +
+ )} + + {!planLoading && !plan && !planError && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/pomodoro/PomodoroTimer.tsx b/frontend/src/components/pomodoro/PomodoroTimer.tsx index 9dd8285..ba53772 100644 --- a/frontend/src/components/pomodoro/PomodoroTimer.tsx +++ b/frontend/src/components/pomodoro/PomodoroTimer.tsx @@ -12,6 +12,7 @@ import { TimerDisplay } from './TimerDisplay'; import { TimerControls } from './TimerControls'; import { SessionManager } from './SessionManager'; import { ModeSelector } from './ModeSelector'; +import { AdaptiveTimerSuggestion } from './AdaptiveTimerSuggestion'; // Sidebar import { CalendarCard } from './sidebar/CalendarCard'; @@ -61,6 +62,7 @@ export function PomodoroTimer() { const [showMoodModal, setShowMoodModal] = useState(false); const [focusMode, setFocusMode] = useState(false); + const [overrideDuration, setOverrideDuration] = useState(null); const status = isActive ? 'running' @@ -71,17 +73,23 @@ export function PomodoroTimer() { const sessionStartTime = useRef(null); const audioRef = useRef(null); - // Sync duration from settings + // Sync duration from settings or override useEffect(() => { if (!isActive) { - let newDuration = settings.pomodoroDuration * 60; - if (mode === 'short-break') newDuration = settings.shortBreakDuration * 60; - if (mode === 'long-break') newDuration = settings.longBreakDuration * 60; - if (totalDuration !== newDuration) { - useTimerStore.getState().setTotalDuration(newDuration); + if (overrideDuration !== null && mode === 'pomodoro') { + if (totalDuration !== overrideDuration) { + useTimerStore.getState().setTotalDuration(overrideDuration); + } + } else { + let newDuration = settings.pomodoroDuration * 60; + if (mode === 'short-break') newDuration = settings.shortBreakDuration * 60; + if (mode === 'long-break') newDuration = settings.longBreakDuration * 60; + if (totalDuration !== newDuration) { + useTimerStore.getState().setTotalDuration(newDuration); + } } } - }, [settings, mode, isActive, totalDuration]); + }, [settings, mode, isActive, totalDuration, overrideDuration]); const handleStart = useCallback(() => { if (!isActive) { @@ -98,6 +106,7 @@ export function PomodoroTimer() { const handleReset = useCallback(() => { resetTimer(); sessionStartTime.current = null; + setOverrideDuration(null); // Clear override on reset }, [resetTimer]); useEffect(() => { @@ -148,6 +157,7 @@ export function PomodoroTimer() { setShowMoodModal(false); resetTimer(); sessionStartTime.current = null; + setOverrideDuration(null); // Clear override on completion }; const formatTime = (seconds: number) => { @@ -201,7 +211,15 @@ export function PomodoroTimer() { {/* ════════════════════════════ CENTER — TIMER ════════════════════════════ */} -
+
+ {mode === 'pomodoro' && ( +
+ setOverrideDuration(focus * 60)} + /> +
+ )} + {/* Card shell */}
diff --git a/frontend/src/components/study/QuizGenerator.tsx b/frontend/src/components/study/QuizGenerator.tsx new file mode 100644 index 0000000..6f1d072 --- /dev/null +++ b/frontend/src/components/study/QuizGenerator.tsx @@ -0,0 +1,186 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; +import { HelpCircle, RefreshCw, Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { generateQuiz } from '@/services/aiApi'; +import { toast } from 'sonner'; + +interface Question { + question: string; + options: string[]; + correctAnswerIndex: number; + explanation: string; +} + +export function QuizGenerator() { + const [loading, setLoading] = useState(false); + const [quizData, setQuizData] = useState<{ title: string; questions: Question[] } | null>(null); + + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [selectedOption, setSelectedOption] = useState(null); + const [showResult, setShowResult] = useState(false); + const [score, setScore] = useState(0); + + const handleGenerate = async () => { + setLoading(true); + setQuizData(null); + try { + const result = await generateQuiz(); + if (result.error) { + toast.error(result.error); + } else if (result.quiz) { + setQuizData(result.quiz); + setCurrentQuestionIndex(0); + setSelectedOption(null); + setShowResult(false); + setScore(0); + } + } catch { + toast.error('Failed to generate quiz'); + } finally { + setLoading(false); + } + }; + + const handleOptionClick = (index: number) => { + if (showResult) return; + setSelectedOption(index); + }; + + const handleSubmit = () => { + if (selectedOption === null || !quizData) return; + + setShowResult(true); + if (selectedOption === quizData.questions[currentQuestionIndex].correctAnswerIndex) { + setScore(s => s + 1); + } + }; + + const handleNext = () => { + if (!quizData) return; + + if (currentQuestionIndex < quizData.questions.length - 1) { + setCurrentQuestionIndex(i => i + 1); + setSelectedOption(null); + setShowResult(false); + } else { + // Quiz finished, show final score (already handled by condition in render) + setCurrentQuestionIndex(i => i + 1); // Move past last question + } + }; + + return ( + + +
+ + + Pop Quiz + + +
+
+ + + {loading ? ( +
+ +

Reading notes and generating questions...

+
+ ) : !quizData ? ( +
+
+ +
+

+ Generate a quick 5-question quiz based on your uploaded notes to test your knowledge. +

+
+ ) : currentQuestionIndex >= quizData.questions.length ? ( +
+

Quiz Complete!

+
+ {score} / {quizData.questions.length} +
+

+ {score === quizData.questions.length ? 'Perfect score! You really know this material.' : + score > quizData.questions.length / 2 ? 'Good job! Keep reviewing to get 100%.' : + 'Time to hit the notes again.'} +

+ +
+ ) : ( +
+
+ Question {currentQuestionIndex + 1} of {quizData.questions.length} + {quizData.title} +
+ +

{quizData.questions[currentQuestionIndex].question}

+ +
+ {quizData.questions[currentQuestionIndex].options.map((opt, i) => { + const isCorrect = i === quizData.questions[currentQuestionIndex].correctAnswerIndex; + const isSelected = i === selectedOption; + + let btnStyle = "border-border/50 hover:border-primary/50 hover:bg-primary/5"; + + if (showResult) { + if (isCorrect) btnStyle = "border-emerald-500/50 bg-emerald-500/10 text-emerald-500"; + else if (isSelected && !isCorrect) btnStyle = "border-red-500/50 bg-red-500/10 text-red-500"; + else btnStyle = "opacity-50 border-border/20"; + } else if (isSelected) { + btnStyle = "border-primary bg-primary/10 text-foreground"; + } + + return ( + + ); + })} +
+ + {showResult && ( +
+ Explanation: + {quizData.questions[currentQuestionIndex].explanation} +
+ )} +
+ )} +
+ + {quizData && currentQuestionIndex < quizData.questions.length && ( + + {!showResult ? ( + + ) : ( + + )} + + )} +
+ ); +} diff --git a/frontend/src/components/study/StudyAssistant.tsx b/frontend/src/components/study/StudyAssistant.tsx new file mode 100644 index 0000000..09262e9 --- /dev/null +++ b/frontend/src/components/study/StudyAssistant.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Bot, Send, Loader2, BookOpen, FileText } from 'lucide-react'; +import { queryRag } from '@/services/aiApi'; + +export function StudyAssistant() { + const [query, setQuery] = useState(''); + const [conversation, setConversation] = useState<{ role: 'user' | 'assistant'; text: string; context?: string[] }[]>([ + { role: 'assistant', text: 'Hi! Ask me any question based on your uploaded study notes.' } + ]); + const [loading, setLoading] = useState(false); + + const handleAsk = async () => { + if (!query.trim()) return; + + const userMessage = query.trim(); + setQuery(''); + setConversation(prev => [...prev, { role: 'user', text: userMessage }]); + setLoading(true); + + try { + const result = await queryRag(userMessage); + if (result.error) { + setConversation(prev => [...prev, { role: 'assistant', text: result.error }]); + } else { + setConversation(prev => [...prev, { role: 'assistant', text: result.answer, context: result.context }]); + } + } catch (err) { + setConversation(prev => [...prev, { role: 'assistant', text: 'Sorry, I encountered an error answering your question.' }]); + } finally { + setLoading(false); + } + }; + + return ( + + + + + Notes Q&A Assistant + + + + +
+ {conversation.map((msg, i) => ( +
+
+
{msg.text}
+ {msg.context && msg.context.length > 0 && ( +
+

+ Sources used: +

+ {msg.context.map((c, j) => ( +
+ + "{c}" +
+ ))} +
+ )} +
+
+ ))} + {loading && ( +
+
+ + Thinking... +
+
+ )} +
+ +
+
+ setQuery(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAsk()} + placeholder="Ask a question about your notes..." + className="bg-card" + disabled={loading} + /> + +
+
+
+
+ ); +} From ba30f47e9de3500ce6ef78113960324ac5963aca Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:53:53 +0530 Subject: [PATCH 11/30] feat: implement RAG pipeline with document processing, vector search --- frontend/src/App.tsx | 3 + frontend/src/pages/StudyPage.tsx | 161 +++++++++++++++++++++++++++++++ frontend/src/services/aiApi.ts | 27 ++++++ 3 files changed, 191 insertions(+) create mode 100644 frontend/src/pages/StudyPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f28d98..b506db4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,9 @@ const EditProfilePage = lazy(() => const Calendar = lazy(() => import('./components/Calendar').then((module) => ({ default: module.Calendar })) ); +const StudyPage = lazy(() => + import('./pages/StudyPage').then((module) => ({ default: module.StudyPage })) +); const LandingPageModern = lazy(() => import('./pages/LandingPageModern').then((module) => ({ default: module.LandingPageModern })) ); diff --git a/frontend/src/pages/StudyPage.tsx b/frontend/src/pages/StudyPage.tsx new file mode 100644 index 0000000..ac76494 --- /dev/null +++ b/frontend/src/pages/StudyPage.tsx @@ -0,0 +1,161 @@ +import { useState, useEffect } from 'react'; +import { motion, type Variants } from 'framer-motion'; +import { UploadCloud, FileText, Loader2, BookOpen } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { uploadDocument, fetchDocuments } from '@/services/aiApi'; +import { StudyAssistant } from './study/StudyAssistant'; +import { QuizGenerator } from './study/QuizGenerator'; +import { toast } from 'sonner'; + +const item: Variants = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 300, damping: 24 } }, +}; + +export function StudyPage() { + const [documents, setDocuments] = useState([]); + const [uploading, setUploading] = useState(false); + const [loadingDocs, setLoadingDocs] = useState(true); + + useEffect(() => { + loadDocuments(); + }, []); + + const loadDocuments = async () => { + try { + const result = await fetchDocuments(); + setDocuments(result.documents || []); + } catch { + toast.error('Failed to load documents'); + } finally { + setLoadingDocs(false); + } + }; + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.type !== 'application/pdf') { + toast.error('Only PDF files are supported currently.'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + toast.error('File size must be under 5MB.'); + return; + } + + setUploading(true); + try { + await uploadDocument(file); + toast.success('Document processed successfully!'); + loadDocuments(); + } catch (err) { + toast.error('Failed to process document'); + } finally { + setUploading(false); + if (e.target) e.target.value = ''; // Reset input + } + }; + + return ( +
+
+
+

+ + Study Assistant +

+

+ Upload your notes to chat with them or generate pop quizzes. +

+
+
+ +
+ {/* Left Column: Documents & Upload */} + + + + + + Your Notes + + + +
+ + +
+ +
+

+ Uploaded Documents +

+ {loadingDocs ? ( +
+ +
+ ) : documents.length === 0 ? ( +

+ No documents uploaded yet. +

+ ) : ( +
+ {documents.map((doc) => ( +
+ +
+

{doc.title}

+

+ {new Date(doc.uploadedAt).toLocaleDateString()} · {(doc.size / 1024 / 1024).toFixed(2)} MB +

+
+
+ ))} +
+ )} +
+
+
+
+ + {/* Middle Column: Q&A Assistant */} + + + + + {/* Right Column: Quiz Generator */} + + + +
+
+ ); +} diff --git a/frontend/src/services/aiApi.ts b/frontend/src/services/aiApi.ts index 40369b9..b1cb283 100644 --- a/frontend/src/services/aiApi.ts +++ b/frontend/src/services/aiApi.ts @@ -141,3 +141,30 @@ export async function fetchAdaptiveTimer(): Promise { return data; } +// ── RAG Study Assistant ───────────────────────────────────────── +export async function uploadDocument(file: File) { + const formData = new FormData(); + formData.append('file', file); + const { data } = await api.post('/ai/documents', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return data; +} + +export async function fetchDocuments() { + const { data } = await api.get('/ai/documents'); + return data; +} + +export async function queryRag(query: string) { + const { data } = await api.post('/ai/rag/query', { query }); + return data; +} + +export async function generateQuiz(topic?: string) { + const { data } = await api.post('/ai/rag/quiz', { topic }); + return data; +} + From b248868d5d4625ceab4f133916decf0693ebdaea Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:54:27 +0530 Subject: [PATCH 12/30] feat: implement RAG pipeline with document processing, vector search, and adaptive focus timer suggestions --- backend/src/services/ai/ragAssistant.js | 117 ++++++++++++++++++ backend/src/services/ai/vectorSearch.js | 81 ++++++++++++ .../src/services/analytics/focusDropoff.js | 101 +++++++++++++++ frontend/src/App.tsx | 1 + frontend/src/components/sidebar/Sidebar.tsx | 3 + 5 files changed, 303 insertions(+) create mode 100644 backend/src/services/ai/ragAssistant.js create mode 100644 backend/src/services/ai/vectorSearch.js create mode 100644 backend/src/services/analytics/focusDropoff.js diff --git a/backend/src/services/ai/ragAssistant.js b/backend/src/services/ai/ragAssistant.js new file mode 100644 index 0000000..ccadd64 --- /dev/null +++ b/backend/src/services/ai/ragAssistant.js @@ -0,0 +1,117 @@ +const { searchSimilarChunks } = require("./vectorSearch"); +const { generate } = require("../llmService"); + +/** + * Ask a question based on uploaded documents. + * + * @param {string} query + * @param {string} userId + */ +async function askQuestion(query, userId) { + // 1. Retrieve relevant chunks + const chunks = await searchSimilarChunks(query, userId, 5); + + if (!chunks || chunks.length === 0) { + return { + answer: "I couldn't find any relevant information in your uploaded notes. Please try rephrasing or upload more documents.", + context: [], + }; + } + + // 2. Build context + const contextText = chunks.map((c, i) => `[Source ${i + 1}]:\n${c.content}`).join("\n\n"); + + // 3. Build prompt + const prompt = `You are an intelligent study assistant. Answer the student's question using ONLY the provided context from their notes. +If the answer is not contained in the context, politely state that you don't know based on the provided notes. + +Context: +${contextText} + +Question: ${query} + +Answer in a clear, educational tone.`; + + // 4. Generate answer + try { + const answer = await generate(prompt, null, { + temperature: 0.2, // Low temp for factual answers + max_tokens: 500, + }); + + return { + answer, + context: chunks.map(c => c.content.substring(0, 100) + "..."), + }; + } catch (error) { + console.error("LLM failed in askQuestion:", error); + return { + error: "Failed to generate an answer. Please try again later." + }; + } +} + +/** + * Generates a multiple-choice quiz from the user's documents. + * We just get random chunks or chunks related to a topic. + * + * @param {string} topic (Optional topic to focus the quiz on) + * @param {string} userId + */ +async function generateQuiz(topic, userId) { + let chunks = []; + + if (topic) { + chunks = await searchSimilarChunks(topic, userId, 5); + } else { + // If no topic, we'd ideally sample random chunks. + // For simplicity, we search for a broad query or just return an error if we can't do random easily via vector search. + chunks = await searchSimilarChunks("key concepts overview summary", userId, 5); + } + + if (!chunks || chunks.length === 0) { + return { + error: "Not enough document content to generate a quiz. Upload notes first." + }; + } + + const contextText = chunks.map(c => c.content).join("\n\n"); + + const prompt = `You are a strict teacher. Based on the following study notes, generate a 5-question multiple choice quiz. +Each question must have exactly 4 options and 1 correct answer. +Return the result strictly as a JSON object matching this schema (no markdown fences): +{ + "title": "Quiz Title", + "questions": [ + { + "question": "Question text", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correctAnswerIndex": 0, + "explanation": "Brief explanation of why this is correct" + } + ] +} + +Study Notes: +${contextText}`; + + try { + let response = await generate(prompt, null, { + temperature: 0.3, + max_tokens: 1500, + }); + + let cleaned = response.trim(); + if (cleaned.startsWith("```")) { + cleaned = cleaned.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, ""); + } + + const quiz = JSON.parse(cleaned); + return { quiz }; + } catch (error) { + console.error("Failed to generate quiz:", error); + return { error: "Failed to generate quiz. Please try again." }; + } +} + +module.exports = { askQuestion, generateQuiz }; diff --git a/backend/src/services/ai/vectorSearch.js b/backend/src/services/ai/vectorSearch.js new file mode 100644 index 0000000..6bb5b2b --- /dev/null +++ b/backend/src/services/ai/vectorSearch.js @@ -0,0 +1,81 @@ +const { GoogleGenerativeAI } = require("@google/generative-ai"); +const DocumentChunk = require("../../models/DocumentChunk"); +const Anthropic = require("@anthropic-ai/sdk"); // If using Anthropic for embeddings, though Anthropic Voyage is separate. We'll use Gemini since we are mocking/using Google AI or standard implementations. + +// In a real scenario, this would use a proper embedding model. +// Since the llmService currently uses Anthropic or Google, and Anthropic doesn't have a native text-embedding-ada-002 equivalent (except Voyage), +// we will assume Google Generative AI for embeddings if key is present, otherwise return a mock embedding array. + +let genAI = null; +if (process.env.GEMINI_API_KEY) { + genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); +} + +/** + * Generates a vector embedding for a given text. + * + * @param {string} text + * @returns {Promise} + */ +async function generateEmbedding(text) { + if (!genAI) { + // Return mock 768-dimensional vector if no API key + console.warn("No GEMINI_API_KEY found, using mock embedding."); + return Array.from({ length: 768 }, () => Math.random() * 2 - 1); + } + + try { + const model = genAI.getGenerativeModel({ model: "embedding-001" }); + const result = await model.embedContent(text); + const embedding = result.embedding; + return embedding.values; + } catch (error) { + console.error("Error generating embedding:", error); + // Fallback mock + return Array.from({ length: 768 }, () => Math.random() * 2 - 1); + } +} + +/** + * Performs a vector search using MongoDB Atlas Vector Search. + * + * @param {string} query + * @param {string} userId + * @param {number} topK + */ +async function searchSimilarChunks(query, userId, topK = 5) { + const queryEmbedding = await generateEmbedding(query); + + try { + // NOTE: This requires a search index named "vector_index" created in Atlas + const results = await DocumentChunk.aggregate([ + { + $vectorSearch: { + index: "vector_index", + path: "embedding", + queryVector: queryEmbedding, + numCandidates: topK * 10, + limit: topK, + filter: { user: { $eq: userId } }, + } + }, + { + $project: { + content: 1, + score: { $meta: "vectorSearchScore" } + } + } + ]); + + return results; + } catch (err) { + console.error("Atlas Vector Search failed. Is it enabled on this cluster?", err.message); + + // Fallback: regular text search if vector search fails + // We would need a text index on content: { $text: { $search: query } } + // Just returning an empty array for now since we rely on vector search. + return []; + } +} + +module.exports = { generateEmbedding, searchSimilarChunks }; diff --git a/backend/src/services/analytics/focusDropoff.js b/backend/src/services/analytics/focusDropoff.js new file mode 100644 index 0000000..4782d61 --- /dev/null +++ b/backend/src/services/analytics/focusDropoff.js @@ -0,0 +1,101 @@ +const Session = require("../../models/Session"); + +/** + * Analyzes the user's session history to find their personal optimal focus duration. + * It groups completed focus sessions by duration and calculates the completion rate + * to suggest ideal focus and break durations. + * + * @param {string} userId - Mongoose ObjectId string + * @returns {Promise} Adaptive timer suggestion object + */ +async function analyzeFocusDropoff(userId) { + // Fetch recent focus sessions + const sessions = await Session.find({ + user: userId, + type: "focus", + }).sort({ startTime: -1 }).limit(100).lean(); + + if (sessions.length < 10) { + return { + hasEnoughData: false, + message: "Not enough data yet for adaptive suggestions.", + }; + } + + // Group by duration buckets (e.g., 25, 30, 35, 40, etc.) + const durationBuckets = {}; + + sessions.forEach((s) => { + // If the session has a targeted duration, we could use that. + // If we only have actual duration and completed flag, we'll try to guess the intended duration + // from actual duration if it's completed, but normally we'd need intended duration. + // Assuming `duration` is the intended duration in seconds and `completed` indicates if they made it. + + // Convert duration to minutes and round to nearest 5 + const durationMin = Math.round(s.duration / 60 / 5) * 5; + + // Ignore unusually short or long sessions (< 10 min or > 120 min) + if (durationMin < 10 || durationMin > 120) return; + + if (!durationBuckets[durationMin]) { + durationBuckets[durationMin] = { total: 0, completed: 0 }; + } + + durationBuckets[durationMin].total += 1; + if (s.completed) { + durationBuckets[durationMin].completed += 1; + } + }); + + const dataPoints = Object.keys(durationBuckets).map((d) => { + const bucket = durationBuckets[d]; + return { + duration: parseInt(d, 10), + total: bucket.total, + completed: bucket.completed, + completionRate: bucket.total > 0 ? bucket.completed / bucket.total : 0, + }; + }).filter(dp => dp.total >= 3); // Only consider buckets with at least 3 sessions + + if (dataPoints.length === 0) { + return { + hasEnoughData: false, + message: "Not enough grouped data for reliable adaptive suggestions.", + }; + } + + // Find the highest duration that has a completion rate > 75% + dataPoints.sort((a, b) => b.duration - a.duration); + + let suggestedFocusDuration = 25; // Default fallback + + for (const dp of dataPoints) { + if (dp.completionRate >= 0.75) { + suggestedFocusDuration = dp.duration; + break; + } + } + + // If no duration had > 75% completion, find the highest completion rate + if (suggestedFocusDuration === 25) { + dataPoints.sort((a, b) => b.completionRate - a.completionRate); + if (dataPoints.length > 0 && dataPoints[0].completionRate > 0) { + suggestedFocusDuration = dataPoints[0].duration; + } + } + + // Calculate recommended breaks based on focus duration + const suggestedShortBreak = suggestedFocusDuration <= 30 ? 5 : 10; + const suggestedLongBreak = suggestedFocusDuration <= 30 ? 15 : 20; + + return { + hasEnoughData: true, + suggestedFocusDuration, + suggestedShortBreak, + suggestedLongBreak, + confidence: dataPoints.length > 3 ? "high" : "medium", + dataPoints, + }; +} + +module.exports = { analyzeFocusDropoff }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b506db4..c39752c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -121,6 +121,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/sidebar/Sidebar.tsx b/frontend/src/components/sidebar/Sidebar.tsx index 9793971..3126615 100644 --- a/frontend/src/components/sidebar/Sidebar.tsx +++ b/frontend/src/components/sidebar/Sidebar.tsx @@ -23,11 +23,14 @@ interface SidebarProps { onOpenChange: (open: boolean) => void; } +import { BookOpen } from 'lucide-react'; + const MENU_ITEMS = [ { path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, { path: '/pomodoro', label: 'Pomodoro', icon: Timer }, { path: '/clock', label: 'Clock In/Out', icon: Clock }, { path: '/tasks', label: 'Tasks', icon: ListTodo }, + { path: '/study', label: 'Study AI', icon: BookOpen }, { path: '/analytics', label: 'Analytics', icon: BarChart2 }, { path: '/calendar', label: 'Calendar', icon: CalendarIcon }, { path: '/spotify', label: 'Spotify', icon: Music }, From 7d599bcdfb63cbfd831d781a7ac8fdcd316ece62 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:54:39 +0530 Subject: [PATCH 13/30] feat: implement PDF document upload, RAG processing, and adaptive timer features --- backend/src/models/Document.js | 24 +++++ backend/src/models/DocumentChunk.js | 44 ++++++++ backend/src/routes/aiRoutes.js | 19 ++++ backend/src/services/ai/documentProcessor.js | 108 +++++++++++++++++++ backend/src/validation/schemas.js | 22 ++++ 5 files changed, 217 insertions(+) create mode 100644 backend/src/models/Document.js create mode 100644 backend/src/models/DocumentChunk.js create mode 100644 backend/src/services/ai/documentProcessor.js diff --git a/backend/src/models/Document.js b/backend/src/models/Document.js new file mode 100644 index 0000000..6814a3b --- /dev/null +++ b/backend/src/models/Document.js @@ -0,0 +1,24 @@ +const mongoose = require("mongoose"); + +const documentSchema = mongoose.Schema( + { + user: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: "User", + index: true, + }, + title: { type: String, required: true }, + filename: { type: String, required: true }, + size: { type: Number, required: true }, + pageCount: { type: Number, default: 0 }, + uploadedAt: { type: Date, default: Date.now }, + }, + { + timestamps: true, + }, +); + +const Document = mongoose.model("Document", documentSchema); + +module.exports = Document; diff --git a/backend/src/models/DocumentChunk.js b/backend/src/models/DocumentChunk.js new file mode 100644 index 0000000..2f52aba --- /dev/null +++ b/backend/src/models/DocumentChunk.js @@ -0,0 +1,44 @@ +const mongoose = require("mongoose"); + +const documentChunkSchema = mongoose.Schema( + { + document: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: "Document", + index: true, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + required: true, + ref: "User", + index: true, + }, + chunkIndex: { type: Number, required: true }, + content: { type: String, required: true }, + // 1536 is standard for text-embedding-ada-002, adjust if using other models like Google embeddings + embedding: { type: [Number] }, + }, + { + timestamps: true, + }, +); + +// We won't create the Atlas Vector Search index via Mongoose (it's managed via MongoDB Atlas UI or API). +// The index typically looks like: +// { +// "mappings": { +// "dynamic": true, +// "fields": { +// "embedding": { +// "dimensions": 1536, +// "similarity": "cosine", +// "type": "knnVector" +// } +// } +// } +// } + +const DocumentChunk = mongoose.model("DocumentChunk", documentChunkSchema); + +module.exports = DocumentChunk; diff --git a/backend/src/routes/aiRoutes.js b/backend/src/routes/aiRoutes.js index c22d8ab..8e70aaa 100644 --- a/backend/src/routes/aiRoutes.js +++ b/backend/src/routes/aiRoutes.js @@ -1,4 +1,5 @@ const express = require("express"); +const multer = require("multer"); const { protect } = require("../middleware/authMiddleware"); const { apiLimiter } = require("../middleware/rateLimitMiddleware"); const { @@ -7,9 +8,18 @@ const { getStudyPlan, regenerateStudyPlan, getRecommendationsHandler, + getAdaptiveTimer, + uploadDocument, + getDocuments, + queryRag, + getQuiz, } = require("../controllers/aiController"); const router = express.Router(); +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit +}); // All AI routes require authentication + rate limiting router.get("/summary", protect, apiLimiter, getAnalyticsSummary); @@ -17,6 +27,15 @@ router.get("/insights", protect, apiLimiter, getInsights); router.get("/study-plan", protect, apiLimiter, getStudyPlan); router.post("/study-plan", protect, apiLimiter, regenerateStudyPlan); router.get("/recommendations", protect, apiLimiter, getRecommendationsHandler); +router.get("/adaptive-timer", protect, apiLimiter, getAdaptiveTimer); + +// RAG Routes +router.post("/documents", protect, apiLimiter, upload.single("file"), uploadDocument); +router.get("/documents", protect, apiLimiter, getDocuments); +router.post("/rag/query", protect, apiLimiter, queryRag); +router.post("/rag/quiz", protect, apiLimiter, getQuiz); module.exports = router; + + diff --git a/backend/src/services/ai/documentProcessor.js b/backend/src/services/ai/documentProcessor.js new file mode 100644 index 0000000..e86a5f3 --- /dev/null +++ b/backend/src/services/ai/documentProcessor.js @@ -0,0 +1,108 @@ +const pdfParse = require("pdf-parse"); +const Document = require("../../models/Document"); +const DocumentChunk = require("../../models/DocumentChunk"); +const { generateEmbedding } = require("./vectorSearch"); + +/** + * Splits text into chunks of ~1000 characters with 100 character overlap. + * + * @param {string} text + * @returns {string[]} + */ +function chunkText(text) { + const chunkSize = 1000; + const overlap = 100; + const chunks = []; + + let index = 0; + while (index < text.length) { + let end = index + chunkSize; + + // If not at the end of text, try to find a natural break point (newline or period) + if (end < text.length) { + const nextNewline = text.indexOf('\n', end); + const nextPeriod = text.indexOf('. ', end); + + // If we can find a natural break within 200 chars, use it + if (nextNewline !== -1 && nextNewline - end < 200) { + end = nextNewline + 1; + } else if (nextPeriod !== -1 && nextPeriod - end < 200) { + end = nextPeriod + 2; + } + } else { + end = text.length; + } + + const chunk = text.slice(index, end).trim(); + if (chunk.length > 50) { // Ignore tiny chunks + chunks.push(chunk); + } + + index = end - overlap; + + // Ensure we move forward + if (index <= index - (end - index)) { + index = end; + } + } + + return chunks; +} + +/** + * Processes an uploaded PDF document, chunks it, generates embeddings, and saves to MongoDB. + * + * @param {Buffer} fileBuffer + * @param {string} filename + * @param {number} size + * @param {string} userId + */ +async function processDocument(fileBuffer, filename, size, userId) { + // 1. Parse PDF + const data = await pdfParse(fileBuffer); + const text = data.text; + + // 2. Create Document record + const doc = await Document.create({ + user: userId, + title: filename.replace(/\.[^/.]+$/, ""), // Strip extension + filename, + size, + pageCount: data.numpages || 0, + }); + + // 3. Chunk text + const chunks = chunkText(text); + + // 4. Generate embeddings and save chunks + // We process chunks in batches to avoid rate limits + const batchSize = 10; + let chunkIndex = 0; + + for (let i = 0; i < chunks.length; i += batchSize) { + const batch = chunks.slice(i, i + batchSize); + + const chunkDocs = await Promise.all( + batch.map(async (content) => { + const embedding = await generateEmbedding(content); + return { + document: doc._id, + user: userId, + chunkIndex: chunkIndex++, + content, + embedding, + }; + }) + ); + + await DocumentChunk.insertMany(chunkDocs); + } + + return { + documentId: doc._id, + title: doc.title, + chunksProcessed: chunkIndex, + }; +} + +module.exports = { processDocument, chunkText }; diff --git a/backend/src/validation/schemas.js b/backend/src/validation/schemas.js index c0fce29..31eb66e 100644 --- a/backend/src/validation/schemas.js +++ b/backend/src/validation/schemas.js @@ -200,6 +200,27 @@ const adminFeedbackStatusBodySchema = z }) .strict(); +const studyProfileBodySchema = z + .object({ + stream: z + .enum(["engineering", "medical", "commerce", "competitive", "custom"]) + .optional(), + customStreamName: optionalTrimmedString(100), + subjects: z + .array( + z.object({ + name: safeString("Subject name", 100), + difficulty: z.enum(["easy", "medium", "hard"]).optional(), + }), + ) + .max(20) + .optional(), + examDate: isoDate.optional().nullable(), + weeklyGoalHours: z.coerce.number().min(1).max(100).optional(), + availableHoursPerDay: z.coerce.number().min(0.5).max(16).optional(), + }) + .strict(); + module.exports = { adminFeedbackStatusBodySchema, adminUserStatusBodySchema, @@ -212,6 +233,7 @@ module.exports = { sessionQuerySchema, sessionUpdateBodySchema, spotifyCallbackSchema, + studyProfileBodySchema, taskBodySchema, taskUpdateBodySchema, workLogStopSchema, From cc98c70849a4e76a50a436cec2af2d7fa2f3b1a6 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:55:20 +0530 Subject: [PATCH 14/30] feat: integrate document-based RAG support and adaptive timer analytics via new dependencies --- backend/package-lock.json | 381 +++++++++++++++++++++++- backend/package.json | 3 + backend/src/controllers/aiController.js | 69 +++++ 3 files changed, 452 insertions(+), 1 deletion(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 6419777..0b68ca2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@google/generative-ai": "^0.24.1", "axios": "^1.13.2", "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", @@ -22,7 +23,9 @@ "jsonwebtoken": "^9.0.3", "mongodb": "^7.0.0", "mongoose": "^9.0.0", + "multer": "^2.2.0", "node-cron": "^4.2.1", + "pdf-parse": "^2.4.5", "xss": "^1.0.15", "zod": "^4.3.6" }, @@ -601,6 +604,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@google/generative-ai": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", + "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1056,6 +1068,205 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1626,6 +1837,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1987,9 +2204,19 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2358,6 +2585,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -5042,6 +5284,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -5386,6 +5690,38 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -5562,6 +5898,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5921,6 +6271,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -5933,6 +6291,15 @@ "text-decoder": "^1.1.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -6328,6 +6695,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -6417,6 +6790,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index b82b944..ed9e270 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ "license": "ISC", "description": "", "dependencies": { + "@google/generative-ai": "^0.24.1", "axios": "^1.13.2", "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", @@ -29,7 +30,9 @@ "jsonwebtoken": "^9.0.3", "mongodb": "^7.0.0", "mongoose": "^9.0.0", + "multer": "^2.2.0", "node-cron": "^4.2.1", + "pdf-parse": "^2.4.5", "xss": "^1.0.15", "zod": "^4.3.6" }, diff --git a/backend/src/controllers/aiController.js b/backend/src/controllers/aiController.js index 8a3acc6..ba778d2 100644 --- a/backend/src/controllers/aiController.js +++ b/backend/src/controllers/aiController.js @@ -6,6 +6,10 @@ const { const { generateInsights } = require("../services/ai/insightEngine"); const { generateStudyPlan } = require("../services/ai/studyPlanner"); const { getRecommendations } = require("../services/ai/recommender"); +const { analyzeFocusDropoff } = require("../services/analytics/focusDropoff"); +const { processDocument } = require("../services/ai/documentProcessor"); +const { askQuestion, generateQuiz } = require("../services/ai/ragAssistant"); +const Document = require("../models/Document"); // @desc Get aggregated analytics summary + productivity score // @route GET /api/ai/summary @@ -67,11 +71,76 @@ const getRecommendationsHandler = asyncHandler(async (req, res) => { res.json(result); }); +// @desc Get adaptive timer suggestions based on session history +// @route GET /api/ai/adaptive-timer +// @access Private +const getAdaptiveTimer = asyncHandler(async (req, res) => { + const result = await analyzeFocusDropoff(req.user._id); + res.json(result); +}); + +// @desc Upload document for RAG +// @route POST /api/ai/documents +// @access Private +const uploadDocument = asyncHandler(async (req, res) => { + if (!req.file) { + res.status(400); + throw new Error("No file uploaded"); + } + + // Expects multer to put file in req.file.buffer + const result = await processDocument( + req.file.buffer, + req.file.originalname, + req.file.size, + req.user._id + ); + + res.status(201).json(result); +}); + +// @desc Get all user documents +// @route GET /api/ai/documents +// @access Private +const getDocuments = asyncHandler(async (req, res) => { + const docs = await Document.find({ user: req.user._id }).sort({ uploadedAt: -1 }); + res.json({ documents: docs }); +}); + +// @desc Ask a question based on uploaded documents +// @route POST /api/ai/rag/query +// @access Private +const queryRag = asyncHandler(async (req, res) => { + const { query } = req.body; + if (!query) { + res.status(400); + throw new Error("Query is required"); + } + + const result = await askQuestion(query, req.user._id); + res.json(result); +}); + +// @desc Generate a quiz based on uploaded documents +// @route POST /api/ai/rag/quiz +// @access Private +const getQuiz = asyncHandler(async (req, res) => { + const { topic } = req.body; + const result = await generateQuiz(topic, req.user._id); + res.json(result); +}); + module.exports = { getAnalyticsSummary, getInsights, getStudyPlan, regenerateStudyPlan, getRecommendationsHandler, + getAdaptiveTimer, + uploadDocument, + getDocuments, + queryRag, + getQuiz, }; + From 5157d2cb4b086a101797edd313f04bd519a58f36 Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:31:19 +0530 Subject: [PATCH 15/30] feat: integrate Google Gemini as a primary LLM provider --- .../src/tests/components/Dashboard.test.tsx | 19 +++++++++++++++++++ .../tests/components/PomodoroTimer.test.tsx | 2 +- .../tests/components/SpotifyPanel.test.tsx | 2 +- .../src/tests/components/TaskManager.test.tsx | 4 ++-- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/src/tests/components/Dashboard.test.tsx b/frontend/src/tests/components/Dashboard.test.tsx index db6508d..bcef183 100644 --- a/frontend/src/tests/components/Dashboard.test.tsx +++ b/frontend/src/tests/components/Dashboard.test.tsx @@ -5,6 +5,7 @@ import { useTaskStore } from '@/store/useTaskStore'; import { useSettingsStore } from '@/store/useSettingsStore'; import { useHistoryStore } from '@/store/useHistoryStore'; import { useAuth } from '@/context/AuthContext'; +import { useAiStore } from '@/store/useAiStore'; // Mock Stores and Auth vi.mock('@/store/useTaskStore', () => ({ useTaskStore: vi.fn() })); @@ -36,6 +37,20 @@ vi.mock('framer-motion', () => ({ }, })); +vi.mock('@/store/useAiStore', () => ({ useAiStore: vi.fn() })); + +vi.mock('@/components/dashboard/AiInsightsPanel', () => ({ + AiInsightsPanel: () =>
AiInsightsPanel
, +})); + +vi.mock('@/components/dashboard/StudyPlanCard', () => ({ + StudyPlanCard: () =>
StudyPlanCard
, +})); + +vi.mock('@/components/dashboard/RecommendationsCard', () => ({ + RecommendationsCard: () =>
RecommendationsCard
, +})); + describe('Dashboard', () => { beforeEach(() => { vi.clearAllMocks(); @@ -55,6 +70,10 @@ describe('Dashboard', () => { (useHistoryStore as any).mockReturnValue({ sessions: [], }); + + (useAiStore as any).mockReturnValue({ + fetchStudyProfile: vi.fn(), + }); }); it('renders dashboard components', () => { diff --git a/frontend/src/tests/components/PomodoroTimer.test.tsx b/frontend/src/tests/components/PomodoroTimer.test.tsx index 4acb6f4..cb2ee8a 100644 --- a/frontend/src/tests/components/PomodoroTimer.test.tsx +++ b/frontend/src/tests/components/PomodoroTimer.test.tsx @@ -117,7 +117,7 @@ describe('PomodoroTimer', () => { it('switches modes', () => { renderTimer(); - const shortBreakBtn = screen.getByText('Short'); + const shortBreakBtn = screen.getByText('Short Break'); fireEvent.click(shortBreakBtn); expect(mockSetMode).toHaveBeenCalledWith('short-break'); }); diff --git a/frontend/src/tests/components/SpotifyPanel.test.tsx b/frontend/src/tests/components/SpotifyPanel.test.tsx index ecfd97f..ac76b3f 100644 --- a/frontend/src/tests/components/SpotifyPanel.test.tsx +++ b/frontend/src/tests/components/SpotifyPanel.test.tsx @@ -35,7 +35,7 @@ describe('SpotifyPanel', () => { it('shows player controls when connected', async () => { // Mock connection check returning true with item - mockedAxios.get.mockResolvedValueOnce({ + mockedAxios.get.mockResolvedValue({ data: { connected: true, item: { diff --git a/frontend/src/tests/components/TaskManager.test.tsx b/frontend/src/tests/components/TaskManager.test.tsx index 5f4b96f..4cf38d5 100644 --- a/frontend/src/tests/components/TaskManager.test.tsx +++ b/frontend/src/tests/components/TaskManager.test.tsx @@ -11,8 +11,8 @@ vi.mock('@/store/useTaskStore', () => ({ // Mock framer-motion to avoid animation issues in tests vi.mock('framer-motion', () => ({ motion: { - div: ({ children, className, ...props }: any) => ( -
+ div: ({ children, className, 'data-testid': testId, id, onClick, style }: any) => ( +
{children}
), From c219a69d8ae895b595a916bee1a43cd7af06b8cd Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:31:57 +0530 Subject: [PATCH 16/30] feat: integrate LLM provider and optimize service worker registration for development environments --- backend/.env.example | 7 +++++++ frontend/src/main.tsx | 20 ++++++++++++++++++++ frontend/src/pages/StudyPage.tsx | 4 ++-- frontend/src/services/aiApi.ts | 6 ++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index e1d86fb..cc5139e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,3 +15,10 @@ SPOTIFY_REDIRECT_URI=http://localhost:5000/api/spotify/callback # Vercel sends this as: Authorization: Bearer # Generate one with: openssl rand -hex 32 CRON_SECRET=replace_with_a_random_32_character_secret + +# AI / LLM Integration +# Set one of the following depending on which LLM provider you prefer to use. +# For RAG (Phase 4), GEMINI_API_KEY is strongly recommended as it handles Vector Embeddings natively here. +GEMINI_API_KEY=your_gemini_api_key_here +ANTHROPIC_API_KEY=your_anthropic_api_key_here +DEFAULT_LLM_MODEL=gemini-1.5-flash diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 11bc9ef..3542a85 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -13,3 +13,23 @@ createRoot(document.getElementById('root')!).render( ); + +// Register Service Worker in production, unregister in development +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + if (import.meta.env.PROD) { + navigator.serviceWorker + .register('/sw.js') + .then((reg) => console.log('SW registered: ', reg)) + .catch((err) => console.log('SW registration failed: ', err)); + } else { + // In Dev, unregister any existing service workers to prevent caching Vite assets + navigator.serviceWorker.getRegistrations().then((registrations) => { + for (const registration of registrations) { + registration.unregister(); + console.log('SW unregistered for development'); + } + }); + } + }); +} diff --git a/frontend/src/pages/StudyPage.tsx b/frontend/src/pages/StudyPage.tsx index ac76494..3bd55dd 100644 --- a/frontend/src/pages/StudyPage.tsx +++ b/frontend/src/pages/StudyPage.tsx @@ -4,8 +4,8 @@ import { UploadCloud, FileText, Loader2, BookOpen } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { uploadDocument, fetchDocuments } from '@/services/aiApi'; -import { StudyAssistant } from './study/StudyAssistant'; -import { QuizGenerator } from './study/QuizGenerator'; +import { StudyAssistant } from '@/components/study/StudyAssistant'; +import { QuizGenerator } from '@/components/study/QuizGenerator'; import { toast } from 'sonner'; const item: Variants = { diff --git a/frontend/src/services/aiApi.ts b/frontend/src/services/aiApi.ts index b1cb283..4847205 100644 --- a/frontend/src/services/aiApi.ts +++ b/frontend/src/services/aiApi.ts @@ -135,6 +135,12 @@ export async function regenerateStudyPlan(): Promise { return data; } +// ── Recommendations ────────────────────────────────────────────── +export async function fetchRecommendations(): Promise { + const { data } = await api.get('/ai/recommendations'); + return data; +} + // ── Adaptive Timer ────────────────────────────────────────────── export async function fetchAdaptiveTimer(): Promise { const { data } = await api.get('/ai/adaptive-timer'); From 7b29d83f05963dc9045a4fb4232590553060240f Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:32:16 +0530 Subject: [PATCH 17/30] feat: integrate logic, and clean up frontend registration and unused dependencies. --- backend/src/services/ai/vectorSearch.js | 1 - backend/src/services/llmService.js | 82 +++++++++++++++---------- frontend/index.html | 10 --- frontend/public/sw.js | 10 +++ 4 files changed, 58 insertions(+), 45 deletions(-) diff --git a/backend/src/services/ai/vectorSearch.js b/backend/src/services/ai/vectorSearch.js index 6bb5b2b..d91f9a8 100644 --- a/backend/src/services/ai/vectorSearch.js +++ b/backend/src/services/ai/vectorSearch.js @@ -1,6 +1,5 @@ const { GoogleGenerativeAI } = require("@google/generative-ai"); const DocumentChunk = require("../../models/DocumentChunk"); -const Anthropic = require("@anthropic-ai/sdk"); // If using Anthropic for embeddings, though Anthropic Voyage is separate. We'll use Gemini since we are mocking/using Google AI or standard implementations. // In a real scenario, this would use a proper embedding model. // Since the llmService currently uses Anthropic or Google, and Anthropic doesn't have a native text-embedding-ada-002 equivalent (except Voyage), diff --git a/backend/src/services/llmService.js b/backend/src/services/llmService.js index 4b56cc2..0949d06 100644 --- a/backend/src/services/llmService.js +++ b/backend/src/services/llmService.js @@ -1,46 +1,60 @@ const axios = require("axios"); +const { GoogleGenerativeAI } = require("@google/generative-ai"); const ANTHROPIC_ENDPOINT = "https://api.anthropic.com/v1/complete"; async function generate(prompt, model, options = {}) { - const chosenModel = - model || process.env.DEFAULT_LLM_MODEL || "claude-haiku-4.5"; - - if (!process.env.ANTHROPIC_API_KEY) { - throw new Error( - "No LLM provider configured. Set ANTHROPIC_API_KEY to enable Claude requests.", - ); + // Use Gemini if configured + if (process.env.GEMINI_API_KEY) { + try { + const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); + const chosenModel = model || process.env.DEFAULT_LLM_MODEL || "gemini-1.5-flash"; + const generativeModel = genAI.getGenerativeModel({ model: chosenModel }); + + const result = await generativeModel.generateContent(prompt); + const response = result.response; + return response.text(); + } catch (err) { + console.error("Gemini API failed:", err.message); + throw new Error(`LLM request failed (Gemini): ${err.message}`); + } } - const max_tokens = options.max_tokens || 512; - - const payload = { - model: chosenModel, - prompt, - max_tokens, - temperature: options.temperature ?? 0.2, - }; - - const headers = { - Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}`, - "Content-Type": "application/json", - }; - - try { - const res = await axios.post(ANTHROPIC_ENDPOINT, payload, { headers }); - - if (res.data && (res.data.output || res.data.completion || res.data.text)) { - return res.data.output || res.data.completion || res.data.text; + // Fallback to Anthropic if configured + if (process.env.ANTHROPIC_API_KEY) { + const chosenModel = model || process.env.DEFAULT_LLM_MODEL || "claude-haiku-4.5"; + const max_tokens = options.max_tokens || 512; + + const payload = { + model: chosenModel, + prompt, + max_tokens, + temperature: options.temperature ?? 0.2, + }; + + const headers = { + Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}`, + "Content-Type": "application/json", + }; + + try { + const res = await axios.post(ANTHROPIC_ENDPOINT, payload, { headers }); + + if (res.data && (res.data.output || res.data.completion || res.data.text)) { + return res.data.output || res.data.completion || res.data.text; + } + + return JSON.stringify(res.data); + } catch (err) { + const message = + err.response && err.response.data + ? JSON.stringify(err.response.data) + : err.message; + throw new Error(`LLM request failed (Anthropic): ${message}`); } - - return JSON.stringify(res.data); - } catch (err) { - const message = - err.response && err.response.data - ? JSON.stringify(err.response.data) - : err.message; - throw new Error(`LLM request failed: ${message}`); } + + throw new Error("No LLM provider configured. Set GEMINI_API_KEY or ANTHROPIC_API_KEY."); } module.exports = { generate }; diff --git a/frontend/index.html b/frontend/index.html index 4b8f7e6..7eafb82 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -117,15 +117,5 @@
- diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 6dbbbad..a196dcd 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -26,6 +26,16 @@ self.addEventListener('activate', (event) => { }); self.addEventListener('fetch', (event) => { + // Only intercept http and https requests (bypasses chrome-extension:// etc.) + if (!event.request.url.startsWith('http')) { + return; + } + + // Bypass service worker for Vite HMR and dev assets + if (event.request.url.includes('@vite/client') || event.request.url.includes('@react-refresh')) { + return; + } + // Navigation requests: Network first, fall back to cache, then offline page (or index.html) if (event.request.mode === 'navigate') { event.respondWith( From 19f1fb2546107faf4e5fc65ef0ba347e15c783fc Mon Sep 17 00:00:00 2001 From: codxbrexx <168920439+codxbrexx@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:30:46 +0530 Subject: [PATCH 18/30] feat: improve landing page --- .../DetailedFeaturesSection.tsx | 85 ++++++-- .../landing-page-modern/header/Header.tsx | 200 ++++-------------- .../landing-page-modern/hero/ModernHero.tsx | 2 +- frontend/src/pages/LandingPageModern.tsx | 127 +---------- 4 files changed, 112 insertions(+), 302 deletions(-) diff --git a/frontend/src/components/landing-page-modern/detailed-features/DetailedFeaturesSection.tsx b/frontend/src/components/landing-page-modern/detailed-features/DetailedFeaturesSection.tsx index 16b56f3..d6d8663 100644 --- a/frontend/src/components/landing-page-modern/detailed-features/DetailedFeaturesSection.tsx +++ b/frontend/src/components/landing-page-modern/detailed-features/DetailedFeaturesSection.tsx @@ -11,6 +11,9 @@ import { SkipForward, Coffee, LogOut, + Sparkles, + MessageSquare, + Bot, } from 'lucide-react'; const detailedFeatures = [ @@ -47,6 +50,18 @@ const detailedFeatures = [ 'Session logs and breakdown reports', ], }, + { + icon: Bot, + color: 'cyan', + title: 'AI that actually helps you focus.', + subtitle: 'FocusMaster AI', + features: [ + 'Upload PDFs to chat with your study materials and generate pop quizzes', + 'Auto-adjusting Pomodoro intervals based on your historical drop-off times', + 'Generates tailored, week-by-week study schedules for upcoming exams', + 'Receive daily actionable insights derived strictly from your focus data', + ], + }, { icon: Music2, color: 'pink', @@ -71,16 +86,13 @@ const detailedFeatures = [ }, ]; -const colorMap: Record = { - indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400', badge: 'bg-indigo-500/20' }, - emerald: { - bg: 'bg-emerald-500/10', - text: 'text-emerald-400', - badge: 'bg-emerald-500/20', - }, - violet: { bg: 'bg-violet-500/10', text: 'text-violet-400', badge: 'bg-violet-500/20' }, - pink: { bg: 'bg-pink-500/10', text: 'text-pink-400', badge: 'bg-pink-500/20' }, - amber: { bg: 'bg-amber-500/10', text: 'text-amber-400', badge: 'bg-amber-500/20' }, +const colorMap: Record = { + indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400', badge: 'bg-indigo-500/20', dot: 'bg-indigo-400' }, + emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', badge: 'bg-emerald-500/20', dot: 'bg-emerald-400' }, + violet: { bg: 'bg-violet-500/10', text: 'text-violet-400', badge: 'bg-violet-500/20', dot: 'bg-violet-400' }, + pink: { bg: 'bg-pink-500/10', text: 'text-pink-400', badge: 'bg-pink-500/20', dot: 'bg-pink-400' }, + amber: { bg: 'bg-amber-500/10', text: 'text-amber-400', badge: 'bg-amber-500/20', dot: 'bg-amber-400' }, + cyan: { bg: 'bg-cyan-500/10', text: 'text-cyan-400', badge: 'bg-cyan-500/20', dot: 'bg-cyan-400' }, }; export const DetailedFeaturesSection = () => { @@ -132,7 +144,7 @@ export const DetailedFeaturesSection = () => { viewport={{ once: true }} >
{feat} @@ -156,7 +168,7 @@ export const DetailedFeaturesSection = () => {
{/* Focus Engine (Timer) */} - {index === 0 && ( + {feature.subtitle === 'Focus Engine' && (
{/* Glowing circular progress mask */} @@ -194,7 +206,7 @@ export const DetailedFeaturesSection = () => { )} {/* Task Manager (Kanban) */} - {index === 1 && ( + {feature.subtitle === 'Task Manager' && (
{/* Column 1 */}
@@ -228,7 +240,7 @@ export const DetailedFeaturesSection = () => { )} {/* Productivity Analytics */} - {index === 2 && ( + {feature.subtitle === 'Productivity Analytics' && (
Weekly Performance @@ -259,7 +271,7 @@ export const DetailedFeaturesSection = () => { )} {/* Spotify Control */} - {index === 3 && ( + {feature.subtitle === 'Spotify Control' && (
{/* album cover art placeholder */} @@ -301,7 +313,7 @@ export const DetailedFeaturesSection = () => { )} {/* Time Tracking */} - {index === 4 && ( + {feature.subtitle === 'Time Tracking' && (
Shift Tracker @@ -324,6 +336,47 @@ export const DetailedFeaturesSection = () => {
)} + + {/* AI Coach */} + {feature.subtitle === 'FocusMaster AI' && ( +
+ {/* Shimmer effect */} +
+ +
+
+ + FocusMaster AI +
+ Online +
+ +
+
+

+ Insight: I noticed your focus drops off around the 40-minute mark. I've adjusted your timer to 35 minutes for optimal retention. +

+
+ +
+
+ + You +
+

Generate a quiz from my physics notes.

+
+ +
+

+ Generating 5 MCQs on Quantum Mechanics.pdf... +

+
+
+
+
+
+
+ )}
diff --git a/frontend/src/components/landing-page-modern/header/Header.tsx b/frontend/src/components/landing-page-modern/header/Header.tsx index bc79fbd..d8ed324 100644 --- a/frontend/src/components/landing-page-modern/header/Header.tsx +++ b/frontend/src/components/landing-page-modern/header/Header.tsx @@ -14,7 +14,7 @@ const Header = () => { const [mobileOpen, setMobileOpen] = useState(false); useEffect(() => { - const handleScroll = () => setIsScrolled(window.scrollY > 12); + const handleScroll = () => setIsScrolled(window.scrollY > 20); window.addEventListener('scroll', handleScroll, { passive: true }); handleScroll(); return () => window.removeEventListener('scroll', handleScroll); @@ -31,81 +31,53 @@ const Header = () => { -
+
{/* ── Logo ── */} FocusMaster - + FocusMaster - {/* Center Nav */} -