diff --git a/README.md b/README.md index a478e66..0431156 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,93 @@ # FocusMaster -![License](https://img.shields.io/badge/license-MIT-blue.svg) -![React](https://img.shields.io/badge/frontend-React-61DAFB?logo=react&logoColor=black) -![Node](https://img.shields.io/badge/backend-Node.js-339933?logo=nodedotjs&logoColor=white) -![MongoDB](https://img.shields.io/badge/database-MongoDB-47A248?logo=mongodb&logoColor=white) -> A unified dashboard designed to maximize your cognitive potential and maintain flow state. +
+ FocusMaster Logo +

The Ultimate AI-Powered Productivity Workspace

+

A beautifully designed, unified dashboard engineered to maximize cognitive potential, maintain deep work flow states, and intelligently adapt to your study and work patterns.

+
-[Features](#key-features) • [Stack](#tech-stack) • [Quick Start](#quick-start) • [Docs](./docs) +

+ License + React + Node + MongoDB + Gemini AI +

---- +## Key Features -### Key Features -- **Pomodoro Timer**: Customizable intervals, visual progress rings, and notification sounds. -- **Kanban Board**: Drag-and-drop workflow tracking with custom tags and priority levels. -- **Deep Analytics**: Interactive productivity heatmap, trends, and session logs. -- **Integrations**: Spotify Premium controller and secure Google OAuth sign-in. -- **Admin Panel**: User role management (RBAC), audit logs, and system metrics. +### Intelligent AI Study Coach & Planner +- **Dynamic Study Planner**: Automatically generates weekly subject schedules based on your stream, exam dates, and available hours. +- **RAG Notes Assistant**: Upload your PDF study materials and chat instantly with an AI that retrieves precise answers directly from your notes. +- **Smart Nudges & Preparation Advice**: Daily AI-generated insights analyzing your focus history to give actionable productivity advice. +- **Floating AI Widget**: A premium, globally accessible AI chat assistant ready to provide coaching and context-aware feedback at any moment. -### Tech Stack -| Frontend | Backend | DevOps & Database | -|---|---|---| -| React 19 + TypeScript | Node.js + Express 5 | MongoDB (Mongoose ODM) | -| Tailwind CSS 4 + Framer Motion | JWT Auth & Google OAuth | Vercel (CI/CD Deployments) | -| Zustand + Shadcn/ui | Jest & Supertest | Vitest + Playwright (E2E) | +### Advanced Productivity Tools +- **Pomodoro Timer**: Customizable focus intervals, visual progress rings, and adaptive timer suggestions based on drop-off analysis. +- **Kanban Task Manager**: Drag-and-drop workflow tracking with custom tags, priority levels, and daily persistence. +- **Clock In/Out Ecosystem**: Comprehensive daily session tracking with rich, calendar-based activity logs. +- **Deep Analytics**: Interactive productivity heatmaps, focus trends, task completion rates, and gamified level progressions. ---- +### Seamless Integrations +- **Spotify Premium Controller**: Manage your deep-work playlists and control playback directly from your dashboard without breaking focus. +- **Secure Authentication**: Robust JWT-based auth and integrated Google OAuth sign-in. +- **Admin Panel**: User role management (RBAC), system metrics, and audit logs. + +## Tech Stack -### Quick Start +| Domain | Technologies | +|---|---| +| **Frontend** | React 19, TypeScript, Tailwind CSS 4, Framer Motion, Zustand, Shadcn/ui, Vite | +| **Backend** | Node.js, Express 5, MongoDB (Mongoose), Google Generative AI (Gemini), Multer | +| **DevOps & Testing** | Vercel (CI/CD), Jest, Supertest, Vitest, Playwright (E2E) | -1. **Clone the repository:** +## Quick Start + +### 1. Clone the repository ```bash -git clone https://github.com/codxbrexx/FocusMaster.git && cd FocusMaster +git clone https://github.com/codxbrexx/FocusMaster.git +cd FocusMaster ``` -2. **Start Backend:** -```bash -cd backend && npm install && npm run dev +### 2. Configure Backend +Create a `.env` file in the `backend/` directory: +```env +PORT=5000 +MONGO_URI=your_mongodb_connection_string +JWT_SECRET=your_super_secret_jwt_key +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +SPOTIFY_CLIENT_ID=your_spotify_client_id +SPOTIFY_CLIENT_SECRET=your_spotify_client_secret +GEMINI_API_KEY=your_google_gemini_api_key +DEFAULT_LLM_MODEL=gemini-2.0-flash ``` -*Configure `backend/.env` with `PORT`, `MONGO_URI`, `JWT_SECRET`, `GOOGLE_CLIENT_ID`, and Spotify credentials.* -3. **Start Frontend:** ```bash -cd frontend && npm install && npm run dev +cd backend +npm install +npm run dev ``` -*Configure `frontend/.env` with `VITE_API_URL` and `VITE_GOOGLE_CLIENT_ID`.* ---- +### 3. Configure Frontend +Create a `.env` file in the `frontend/` directory: +```env +VITE_API_URL=http://localhost:5000/api +VITE_GOOGLE_CLIENT_ID=your_google_client_id +``` + +```bash +cd frontend +npm install +npm run dev +``` -### Development & Resources -- **Testing**: Run `npm test` in the respective `frontend` or `backend` folder. -- **Documentation**: Comprehensive guides and specifications are in the [`docs/`](./docs) folder. +## Documentation +Comprehensive architecture guides, API specifications, and development workflows can be found in the [`docs/`](./docs) directory. --- -**Developer:** [@codxbrexx](https://github.com/codxbrexx) • **License**: MIT +
+ Designed & Developed by @codxbrexx +
+ Productivity Workspace | MIT License +
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/backend/package-lock.json b/backend/package-lock.json index 6419777..554bbbd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@google/genai": "^2.13.0", "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": "1.1.1", "xss": "^1.0.15", "zod": "^4.3.6" }, @@ -601,6 +604,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@google/genai": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.13.0.tgz", + "integrity": "sha512-GM7C8Kaomvjz05x5JEO6+l3d/pciL9LxAG9dUjJLD7nTPZ9X0Cfsf2Z7eET6UjgWyUmxXCHtYnQoQ77F9+ZIOQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1115,6 +1142,63 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@sinclair/typebox": { "version": "0.34.47", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", @@ -1229,12 +1313,17 @@ "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -1626,6 +1715,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 +2082,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 +2463,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", @@ -4642,6 +4762,12 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -5042,6 +5168,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", @@ -5116,6 +5304,12 @@ "node": ">=10.5.0" } }, + "node_modules/node-ensure": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz", + "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==", + "license": "MIT" + }, "node_modules/node-fetch": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", @@ -5287,6 +5481,19 @@ "node": ">=8" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -5386,6 +5593,28 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdf-parse": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz", + "integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==", + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "node-ensure": "^0.0.0" + }, + "engines": { + "node": ">=6.8.1" + } + }, + "node_modules/pdf-parse/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -5464,6 +5693,29 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5562,6 +5814,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", @@ -5608,6 +5874,15 @@ "node": ">=8" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rimraf": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", @@ -5921,6 +6196,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 +6216,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 +6620,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", @@ -6339,7 +6637,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -6417,6 +6714,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", @@ -6608,6 +6911,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xss": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", diff --git a/backend/package.json b/backend/package.json index b82b944..0207dd1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ "license": "ISC", "description": "", "dependencies": { + "@google/genai": "^2.13.0", "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": "1.1.1", "xss": "^1.0.15", "zod": "^4.3.6" }, diff --git a/backend/src/app.js b/backend/src/app.js index cbdf4e8..236943c 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -17,6 +17,8 @@ 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 studyProfileRoutes = require("./routes/studyProfileRoutes"); const app = express(); app.set("trust proxy", 1); @@ -87,6 +89,8 @@ 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.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 new file mode 100644 index 0000000..b3dae3c --- /dev/null +++ b/backend/src/controllers/aiController.js @@ -0,0 +1,409 @@ +const asyncHandler = require("express-async-handler"); +const { addWeeks } = require("date-fns"); +const { aggregateUserStats } = require("../services/analytics/aggregator"); +const { + calculateProductivityScore, +} = require("../services/analytics/productivityScore"); +const { generateInsights, parseInsightResponse } = 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 { generateEmbedding } = require("../services/ai/vectorSearch"); +const { askQuestion, generateQuiz } = require("../services/ai/ragAssistant"); +const { handleStudyChat } = require("../services/ai/chatAssistant"); + +// Models (only the controller touches the database) +const AiInsight = require("../models/AiInsight"); +const User = require("../models/User"); +const StudyPlan = require("../models/StudyPlan"); +const Document = require("../models/Document"); +const DocumentChunk = require("../models/DocumentChunk"); + +const CACHE_HOURS = 24; + +// @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 userId = req.user._id; + + // Check cache + const cached = await AiInsight.findOne({ + user: userId, + expiresAt: { $gt: new Date() }, + }) + .sort({ generatedAt: -1 }) + .lean(); + + if (cached) { + return res.json({ + insights: cached.insights, + recommendations: cached.recommendations, + summary: cached.summary, + prepAdvice: cached.prepAdvice || "Keep up your preparation!", + productivityScore: cached.productivityScore, + scoreBreakdown: cached.scoreBreakdown, + stats: cached.stats, + generatedAt: cached.generatedAt, + fromCache: true, + }); + } + + // Aggregate + Score + const stats = await aggregateUserStats(userId, 30); + const scoreResult = calculateProductivityScore(stats, req.user.settings || {}); + + // Call AI service (pure LLM, no DB) + let parsed; + try { + parsed = await generateInsights(stats, scoreResult); + } catch (err) { + // LLM failed — try stale cache + const stale = await AiInsight.findOne({ user: userId }) + .sort({ generatedAt: -1 }) + .lean(); + + if (stale) { + return res.json({ + insights: stale.insights, + recommendations: stale.recommendations, + summary: stale.summary, + prepAdvice: stale.prepAdvice || "Keep up your preparation!", + productivityScore: scoreResult.score, + scoreBreakdown: scoreResult.breakdown, + stats, + generatedAt: stale.generatedAt, + fromCache: true, + stale: true, + }); + } + + // No cache at all — return fallback + parsed = parseInsightResponse("invalid"); + } + + // 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, + prepAdvice: parsed.prepAdvice, + productivityScore: scoreResult.score, + scoreBreakdown: scoreResult.breakdown, + stats, + generatedAt: now, + expiresAt, + }, + { upsert: true, new: true }, + ); + + res.json({ + insights: saved.insights, + recommendations: saved.recommendations, + summary: saved.summary, + prepAdvice: saved.prepAdvice || parsed.prepAdvice, + productivityScore: scoreResult.score, + scoreBreakdown: scoreResult.breakdown, + stats, + generatedAt: saved.generatedAt, + fromCache: false, + }); +}); + +// @desc Get or generate AI study plan +// @route GET /api/ai/study-plan +// @access Private +const getStudyPlan = asyncHandler(async (req, res) => { + const userId = req.user._id; + + // Check for existing plan + const existing = await StudyPlan.findOne({ user: userId }) + .sort({ generatedAt: -1 }) + .lean(); + + if (existing) { + return res.json({ plan: existing, fromCache: true }); + } + + // Fetch profile and stats, then call AI service + const user = await User.findById(userId).select("studyProfile settings"); + const profile = user.studyProfile || {}; + const stats = await aggregateUserStats(userId, 30); + + const result = await generateStudyPlan(profile, stats); + + if (result.error) { + return res.status(result.weeks ? 200 : 400).json({ plan: null, error: result.error }); + } + + // Add dates and save + const saved = await _saveStudyPlan(userId, result.weeks, profile); + res.json({ plan: saved, fromCache: false }); +}); + +// @desc Regenerate AI study plan +// @route POST /api/ai/study-plan +// @access Private +const regenerateStudyPlan = asyncHandler(async (req, res) => { + const userId = req.user._id; + + const user = await User.findById(userId).select("studyProfile settings"); + const profile = user.studyProfile || {}; + const stats = await aggregateUserStats(userId, 30); + + let result; + try { + result = await generateStudyPlan(profile, stats); + } catch (err) { + // LLM failed — return existing plan or error + const fallback = await StudyPlan.findOne({ user: userId }) + .sort({ generatedAt: -1 }) + .lean(); + + if (fallback) { + return res.json({ plan: fallback, fromCache: true, stale: true }); + } + return res.status(500).json({ plan: null, error: "Failed to generate study plan. Please try again later." }); + } + + if (result.error) { + return res.status(result.weeks ? 200 : 400).json({ plan: null, error: result.error }); + } + + const saved = await _saveStudyPlan(userId, result.weeks, profile); + res.json({ plan: saved, fromCache: false }); +}); + +/** + * Internal helper to date-stamp and persist a study plan. + */ +async function _saveStudyPlan(userId, weeks, profile) { + const now = new Date(); + const weeksWithDates = weeks.map((week, i) => ({ + ...week, + weekNumber: i + 1, + startDate: addWeeks(now, i), + endDate: addWeeks(now, i + 1), + })); + + return StudyPlan.findOneAndUpdate( + { user: userId }, + { + user: userId, + weeks: weeksWithDates, + examDate: profile.examDate, + totalWeeks: weeks.length, + stream: profile.stream, + subjects: (profile.subjects || []).map((s) => s.name), + generatedAt: now, + }, + { upsert: true, new: true }, + ); +} + +// @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); +}); + +// @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"); + } + + const userId = req.user._id; + + // 1. Parse PDF and chunk (pure function, no DB) + const { chunks, pageCount } = await processDocument(req.file.buffer); + + // 2. Create Document record + const doc = await Document.create({ + user: userId, + title: req.file.originalname.replace(/\.[^/.]+$/, ""), + filename: req.file.originalname, + size: req.file.size, + pageCount, + }); + + // 3. Generate embeddings and save chunks (batched) + 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); + } + + res.status(201).json({ + documentId: doc._id, + title: doc.title, + chunksProcessed: chunkIndex, + }); +}); + +// @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 }); +}); + +/** + * Internal helper to perform vector search via MongoDB Atlas. + */ +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); + return []; + } +} + +// @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"); + } + + // Fetch chunks from DB, then pass to pure AI function + const chunks = await _searchSimilarChunks(query, req.user._id, 5); + const result = await askQuestion(query, chunks); + 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; + + // Fetch chunks from DB, then pass to pure AI function + const searchQuery = topic || "key concepts overview summary"; + const chunks = await _searchSimilarChunks(searchQuery, req.user._id, 5); + const result = await generateQuiz(chunks); + res.json(result); +}); + +// @desc General chat about study preparation and analytics +// @route POST /api/ai/chat +// @access Private +const studyChat = asyncHandler(async (req, res) => { + const { message, history } = req.body; + if (!message) { + res.status(400); + throw new Error("Message is required"); + } + + const userId = req.user._id; + + // Fetch all context from DB + const [stats, user, studyPlan] = await Promise.all([ + aggregateUserStats(userId, 30), + User.findById(userId).select("studyProfile").lean(), + StudyPlan.findOne({ user: userId }).sort({ generatedAt: -1 }).lean(), + ]); + + // Pass structured context to pure AI function + const contextData = { + stats, + studyProfile: user?.studyProfile || null, + studyPlan, + }; + + const result = await handleStudyChat(message, history || [], contextData); + res.json(result); +}); + +module.exports = { + getAnalyticsSummary, + getInsights, + getStudyPlan, + regenerateStudyPlan, + getRecommendationsHandler, + getAdaptiveTimer, + uploadDocument, + getDocuments, + queryRag, + getQuiz, + studyChat, +}; 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/AiInsight.js b/backend/src/models/AiInsight.js new file mode 100644 index 0000000..920c45a --- /dev/null +++ b/backend/src/models/AiInsight.js @@ -0,0 +1,51 @@ +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: "", + }, + prepAdvice: { + 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/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/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/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, diff --git a/backend/src/routes/aiRoutes.js b/backend/src/routes/aiRoutes.js new file mode 100644 index 0000000..7da3543 --- /dev/null +++ b/backend/src/routes/aiRoutes.js @@ -0,0 +1,43 @@ +const express = require("express"); +const multer = require("multer"); +const { protect } = require("../middleware/authMiddleware"); +const { apiLimiter } = require("../middleware/rateLimitMiddleware"); +const { + getAnalyticsSummary, + getInsights, + getStudyPlan, + regenerateStudyPlan, + getRecommendationsHandler, + getAdaptiveTimer, + uploadDocument, + getDocuments, + queryRag, + getQuiz, + studyChat, +} = 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); +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); +router.post("/chat", protect, apiLimiter, studyChat); + +module.exports = router; + + + 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; diff --git a/backend/src/services/ai/chatAssistant.js b/backend/src/services/ai/chatAssistant.js new file mode 100644 index 0000000..0ff8e2a --- /dev/null +++ b/backend/src/services/ai/chatAssistant.js @@ -0,0 +1,73 @@ +const { generate } = require("../llmService"); + +/** + * Handle a general study chat query from the user, providing LLM with context + * about their study profile, plan, and recent focus stats. + * + * This function is a pure data-transformation layer. It does NOT access + * the database. The caller (controller) must fetch stats, profile, and + * plan from the DB and pass them in via the `contextData` argument. + * + * @param {string} message + * @param {Array<{role: string, text: string}>} history + * @param {Object} contextData + * @param {Object} [contextData.stats] + * @param {Object} [contextData.studyProfile] + * @param {Object} [contextData.studyPlan] + */ +async function handleStudyChat(message, history = [], contextData = {}) { + const { stats, studyProfile, studyPlan } = contextData; + + // Format context for LLM + let contextStr = "User Study Context:\n"; + if (studyProfile && studyProfile.stream) { + contextStr += `- Stream/Goal: ${studyProfile.customStreamName || studyProfile.stream}\n`; + contextStr += `- Subjects: ${(studyProfile.subjects || []).map(s => s.name).join(", ")}\n`; + if (studyProfile.examDate) { + contextStr += `- Exam Date: ${new Date(studyProfile.examDate).toLocaleDateString()}\n`; + } + } else { + contextStr += `- Study Profile: Not completely set up yet.\n`; + } + + if (stats) { + contextStr += `- Recent Focus Stats (last 30 days): ${stats.focus.totalSessions} sessions, ${stats.focus.totalMinutes} total minutes focused.\n`; + contextStr += `- Task Completion Rate: ${Math.round(stats.tasks.completionRate)}%\n`; + contextStr += `- Current Streak: ${stats.patterns.currentStreak} days\n`; + } + + if (studyPlan && studyPlan.weeks && studyPlan.weeks.length > 0) { + const currentWeek = studyPlan.weeks[0]; + contextStr += `- Current Study Plan Week: ${currentWeek.weekNumber} (${currentWeek.theme})\n`; + } + + // Build the system prompt + let prompt = `You are an expert, encouraging AI study coach and preparation analyzer. +You are helping a student prepare and analyze their study progress based on their personalized data. + +${contextStr} + +Be concise, supportive, and highly actionable. Answer the user's latest message based on this context. Do not use Markdown headings like # or ## if possible, just use bold text and lists for clean chat rendering. + +Chat History: +`; + + // Append history (limit to last 6 messages to save tokens) + const recentHistory = history.slice(-6); + recentHistory.forEach(msg => { + prompt += `${msg.role === 'user' ? 'Student' : 'Coach'}: ${msg.text}\n`; + }); + + prompt += `Student: ${message}\nCoach:`; + + const answer = await generate(prompt, null, { + temperature: 0.7, + max_tokens: 600, + }); + + return { answer: answer.trim() }; +} + +module.exports = { + handleStudyChat, +}; diff --git a/backend/src/services/ai/documentProcessor.js b/backend/src/services/ai/documentProcessor.js new file mode 100644 index 0000000..6617558 --- /dev/null +++ b/backend/src/services/ai/documentProcessor.js @@ -0,0 +1,73 @@ +const pdfParse = require("pdf-parse"); + +/** + * 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; +} + +/** + * Parses a PDF buffer and returns chunked text strings. + * + * This function is a pure data-transformation layer. It does NOT access + * the database. The caller (controller) is responsible for creating the + * Document record, generating embeddings, and saving DocumentChunk records. + * + * @param {Buffer} fileBuffer - Raw PDF file buffer + * @returns {Promise<{ text: string, chunks: string[], pageCount: number }>} + */ +async function processDocument(fileBuffer) { + // 1. Parse PDF + const data = await pdfParse(fileBuffer); + + // 2. Chunk text + const chunks = chunkText(data.text); + + return { + text: data.text, + chunks, + pageCount: data.numpages || 0, + }; +} + +module.exports = { processDocument, chunkText }; diff --git a/backend/src/services/ai/insightEngine.js b/backend/src/services/ai/insightEngine.js new file mode 100644 index 0000000..76bbca2 --- /dev/null +++ b/backend/src/services/ai/insightEngine.js @@ -0,0 +1,110 @@ +const { generateJSON } = require("../llmService"); + +const INSIGHT_SYSTEM_INSTRUCTION = `You are a productivity coach analyzing a student's study data. + +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`; + +/** + * 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 `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: +{ + "insights": ["insight1", "insight2", "insight3"], + "recommendations": ["recommendation1", "recommendation2"], + "summary": "one motivational sentence", + "prepAdvice": "A specific piece of advice on their exam/subject preparation based on their streak and focus." +}`; +} + +/** + * Validate and clean the parsed LLM JSON response. + */ +function parseInsightResponse(parsed) { + if (!parsed) parsed = {}; + try { + 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!", + prepAdvice: + typeof parsed.prepAdvice === "string" + ? parsed.prepAdvice + : "Stay consistent with your preparation!", + }; + } 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!", + prepAdvice: "Consistency is key to mastering your subjects.", + }; + } +} + +/** + * Generate AI insights from pre-aggregated stats and a pre-computed score. + * + * This function is a pure data-transformation layer: + * 1. Build prompt from stats + score + * 2. Call LLM + * 3. Parse response + * 4. Return structured result + * + * It does NOT access the database. The caller (controller) is responsible + * for cache checks, cache writes, and fetching stats/score. + * + * @param {Object} stats + * @param {Object} scoreResult + * @returns {Promise} + */ +async function generateInsights(stats, scoreResult) { + const prompt = buildInsightPrompt(stats, scoreResult); + try { + const llmResponse = await generateJSON(prompt, { + max_tokens: 400, + temperature: 0.4, + systemInstruction: INSIGHT_SYSTEM_INSTRUCTION, + }); + return parseInsightResponse(llmResponse); + } catch (error) { + console.error("Error generating insights:", error); + return parseInsightResponse(null); + } +} + +module.exports = { generateInsights, buildInsightPrompt, parseInsightResponse }; diff --git a/backend/src/services/ai/ragAssistant.js b/backend/src/services/ai/ragAssistant.js new file mode 100644 index 0000000..1e49a09 --- /dev/null +++ b/backend/src/services/ai/ragAssistant.js @@ -0,0 +1,111 @@ +const { generate } = require("../llmService"); + +/** + * Ask a question based on pre-fetched document chunks. + * + * This function is a pure data-transformation layer. It does NOT access + * the database. The caller (controller) performs the vector search and + * passes the relevant chunks in. + * + * @param {string} query + * @param {Array<{ content: string }>} chunks + * @returns {Promise} + */ +async function askQuestion(query, chunks) { + 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: [], + }; + } + + // Build context + const contextText = chunks.map((c, i) => `[Source ${i + 1}]:\n${c.content}`).join("\n\n"); + + // 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.`; + + // 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 pre-fetched document chunks. + * + * This function is a pure data-transformation layer. It does NOT access + * the database. The caller (controller) performs the vector search and + * passes the relevant chunks in. + * + * @param {Array<{ content: string }>} chunks - Pre-fetched relevant chunks + * @returns {Promise} + */ +async function generateQuiz(chunks) { + 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/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..d17a99f --- /dev/null +++ b/backend/src/services/ai/studyPlanner.js @@ -0,0 +1,134 @@ +const { generate } = require("../llmService"); +const { differenceInWeeks, 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 a study plan from a user's profile and aggregated stats. + * + * This function is a pure data-transformation layer: + * 1. Build prompt from profile + stats + * 2. Call LLM + * 3. Parse response into structured weeks + * 4. Return the parsed weeks array (or null on failure) + * + * It does NOT access the database. The caller (controller) is responsible + * for fetching the profile/stats, checking for existing plans, and saving. + * + * @param {Object} profile + * @param {Object} stats + * @returns {Promise<{ weeks: Array|null, error: string|null }>} + */ +async function generateStudyPlan(profile, stats) { + if (!profile.stream && (!profile.subjects || profile.subjects.length === 0)) { + return { + weeks: null, + error: "Please set up your study profile first (stream and subjects).", + }; + } + + const weeksUntilExam = profile.examDate + ? Math.max(differenceInWeeks(new Date(profile.examDate), new Date()), 1) + : 4; + const planWeeks = Math.min(weeksUntilExam, 8); + + const prompt = buildPlannerPrompt(profile, stats); + const llmResponse = await generate(prompt, null, { + max_tokens: 2000, + temperature: 0.3, + }); + const weeks = parsePlanResponse(llmResponse, planWeeks); + + if (!weeks) { + return { weeks: null, error: "Could not parse AI response. Please try again." }; + } + + return { weeks, error: null }; +} + +module.exports = { generateStudyPlan, buildPlannerPrompt, parsePlanResponse }; diff --git a/backend/src/services/ai/vectorSearch.js b/backend/src/services/ai/vectorSearch.js new file mode 100644 index 0000000..0f07ad1 --- /dev/null +++ b/backend/src/services/ai/vectorSearch.js @@ -0,0 +1,30 @@ +const { generateEmbedding: llmGenerateEmbedding } = require("../llmService"); + +/** + * Generates a vector embedding for a given text. + * + * This is the only function in this module. The actual vector search + * ($vectorSearch aggregation) is handled by the controller, which owns + * all database access. + * + * @param {string} text + * @returns {Promise} + */ +async function generateEmbedding(text) { + if (!process.env.GEMINI_API_KEY) { + // 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 { + // Uses llmService which inherently uses the @google/genai SDK + return await llmGenerateEmbedding(text); + } catch (error) { + console.error("Error generating embedding:", error); + // Fallback mock + return Array.from({ length: 768 }, () => Math.random() * 2 - 1); + } +} + +module.exports = { generateEmbedding }; diff --git a/backend/src/services/analytics/aggregator.js b/backend/src/services/analytics/aggregator.js new file mode 100644 index 0000000..35d7376 --- /dev/null +++ b/backend/src/services/analytics/aggregator.js @@ -0,0 +1,154 @@ +const Session = require("../../models/Session"); +const Task = require("../../models/Task"); +const { + subDays, + 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/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/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/backend/src/services/llmService.js b/backend/src/services/llmService.js index 4b56cc2..522786f 100644 --- a/backend/src/services/llmService.js +++ b/backend/src/services/llmService.js @@ -1,46 +1,191 @@ -const axios = require("axios"); +/** + * LLM Service — Professional Gemini API Integration + * + * Features: + * • @google/genai SDK (GA, replaces deprecated @google/generative-ai) + * • Singleton client — one instance for the app lifecycle + * • JSON mode — `responseMimeType: "application/json"` for structured outputs + * • System instructions — separated from user prompts (lower token cost) + * • Safety settings — prevents unnecessary content blocks for educational content + * • Retry with exponential backoff — handles 429 / 503 gracefully + * • Token usage logging — cost visibility per request + * • Embedding support — shared client for embedding calls + */ -const ANTHROPIC_ENDPOINT = "https://api.anthropic.com/v1/complete"; +const { GoogleGenAI } = require("@google/genai"); -async function generate(prompt, model, options = {}) { - const chosenModel = - model || process.env.DEFAULT_LLM_MODEL || "claude-haiku-4.5"; +// Singleton Client +let _client = null; - if (!process.env.ANTHROPIC_API_KEY) { - throw new Error( - "No LLM provider configured. Set ANTHROPIC_API_KEY to enable Claude requests.", +function getClient() { + if (!_client) { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new Error( + "GEMINI_API_KEY is not set. Add it to your .env file.", + ); + } + _client = new GoogleGenAI({ apiKey }); + } + return _client; +} + +// Default Configuration +const DEFAULT_MODEL = process.env.DEFAULT_LLM_MODEL || "gemini-2.0-flash"; +const DEFAULT_EMBEDDING_MODEL = process.env.DEFAULT_EMBEDDING_MODEL || "gemini-embedding-001"; + +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 1000; + +// Safety settings — tuned for educational/productivity content +const SAFETY_SETTINGS = [ + { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_ONLY_HIGH" }, + { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }, + { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_ONLY_HIGH" }, + { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_ONLY_HIGH" }, +]; + +// Retry Helper +async function withRetry(fn, retries = MAX_RETRIES) { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (err) { + const isRetryable = + err.status === 429 || + err.status === 503 || + err.message?.includes("RESOURCE_EXHAUSTED") || + err.message?.includes("UNAVAILABLE"); + + if (!isRetryable || attempt === retries) { + throw err; + } + + const delay = BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 500; + console.warn( + `[LLM] Retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${retries}) — ${err.message}`, + ); + await new Promise((r) => setTimeout(r, delay)); + } + } +} + +// Token Usage Logger +function logUsage(label, response) { + const usage = response?.usageMetadata; + if (usage) { + console.log( + `[LLM:${label}] Tokens — prompt: ${usage.promptTokenCount ?? "?"}, ` + + `completion: ${usage.candidatesTokenCount ?? "?"}, ` + + `total: ${usage.totalTokenCount ?? "?"}`, ); } +} - const max_tokens = options.max_tokens || 512; +// Generate (Text Output) +/** + * Generate text from a prompt. + * + * @param {string} prompt + * @param {Object} [options] + * @param {string} [options.model] + * @param {string} [options.systemInstruction] + * @param {number} [options.maxOutputTokens] + * @param {number} [options.temperature] + * @param {string} [options.label] + * @returns {Promise} + */ +async function generate(prompt, options = {}) { + const client = getClient(); + const model = options.model || DEFAULT_MODEL; - const payload = { - model: chosenModel, - prompt, - max_tokens, - temperature: options.temperature ?? 0.2, + const config = { + maxOutputTokens: options.maxOutputTokens || options.max_tokens || 512, + temperature: options.temperature ?? 0.4, + safetySettings: SAFETY_SETTINGS, }; - const headers = { - Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}`, - "Content-Type": "application/json", + if (options.systemInstruction) { + config.systemInstruction = options.systemInstruction; + } + + return withRetry(async () => { + const response = await client.models.generateContent({ + model, + contents: prompt, + config, + }); + + logUsage(options.label || model, response); + return response.text; + }); +} + +// Generate JSON (Structured Output) +/** + * Generate structured JSON from a prompt. + * Uses Gemini's native JSON mode — guarantees valid JSON output, + * eliminating the need for "no markdown fences" hacks and fragile parsing. + * + * @param {string} prompt - User/data prompt + * @param {Object} [options] - Generation options (same as generate) + * @returns {Promise} Parsed JSON object + */ +async function generateJSON(prompt, options = {}) { + const client = getClient(); + const model = options.model || DEFAULT_MODEL; + + const config = { + maxOutputTokens: options.maxOutputTokens || options.max_tokens || 512, + temperature: options.temperature ?? 0.3, + responseMimeType: "application/json", + safetySettings: SAFETY_SETTINGS, }; - try { - const res = await axios.post(ANTHROPIC_ENDPOINT, payload, { headers }); + if (options.systemInstruction) { + config.systemInstruction = options.systemInstruction; + } - if (res.data && (res.data.output || res.data.completion || res.data.text)) { - return res.data.output || res.data.completion || res.data.text; + return withRetry(async () => { + const response = await client.models.generateContent({ + model, + contents: prompt, + config, + }); + + logUsage(options.label || `${model}:json`, response); + + const text = response.text; + + try { + return JSON.parse(text); + } catch { + // Shouldn't happen with JSON mode, but safety net + console.error("[LLM] JSON mode returned unparseable text:", text?.substring(0, 200)); + throw new Error("LLM returned invalid JSON despite JSON mode."); } + }); +} - 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}`); - } +// Generate Embedding +/** + * Generate a vector embedding for text. + * + * @param {string} text - Text to embed + * @param {string} [model] - Embedding model name override + * @returns {Promise} Embedding vector + */ +async function generateEmbedding(text, model) { + const client = getClient(); + const embeddingModel = model || DEFAULT_EMBEDDING_MODEL; + + return withRetry(async () => { + const response = await client.models.embedContent({ + model: embeddingModel, + contents: text, + }); + return response.embeddings[0].values; + }); } -module.exports = { generate }; +module.exports = { generate, generateJSON, generateEmbedding, getClient }; 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, diff --git a/frontend/index.html b/frontend/index.html index 4b8f7e6..a0a80a7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -30,7 +30,7 @@ /> - + @@ -43,14 +43,14 @@ name="twitter:description" content="Boost your productivity with FocusMaster. Built-in Pomodoro timer, smart task manager, focus analytics, and Spotify integration." /> - + - - + + @@ -80,7 +80,7 @@ "browserRequirements": "Requires a modern browser with JavaScript enabled", "description": "FocusMaster is a productivity web app featuring a Pomodoro timer, task manager, analytics dashboard, calendar, clock-in/out tracker, and Spotify integration to help you stay focused and productive.", "url": "https://focusmaster-tau.vercel.app", - "image": "https://focusmaster-tau.vercel.app/fmasterlogo.png", + "image": "https://focusmaster-tau.vercel.app/FM_logo.png", "screenshot": "https://focusmaster-tau.vercel.app/dash_admin.png", "softwareVersion": "1.0.0", "offers": { @@ -117,15 +117,5 @@
- diff --git a/frontend/public/FM_logo.png b/frontend/public/FM_logo.png new file mode 100644 index 0000000..2ee1fc7 Binary files /dev/null and b/frontend/public/FM_logo.png differ diff --git a/frontend/public/Focusmaster_logo.svg b/frontend/public/Focusmaster_logo.svg new file mode 100644 index 0000000..b388bc7 --- /dev/null +++ b/frontend/public/Focusmaster_logo.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f28d98..c39752c 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 })) ); @@ -118,6 +121,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index c580433..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'; @@ -9,6 +9,10 @@ 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'; +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.', @@ -24,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; @@ -137,6 +146,13 @@ export function Dashboard() { averageFocusDuration={averageFocusDuration} /> + + +
+ +
+ +
diff --git a/frontend/src/components/FloatingChat.tsx b/frontend/src/components/FloatingChat.tsx new file mode 100644 index 0000000..23d29b0 --- /dev/null +++ b/frontend/src/components/FloatingChat.tsx @@ -0,0 +1,135 @@ +import { useState, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { MessageCircle, X, Send, Loader2, Sparkles } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { sendStudyChat } from '@/services/aiApi'; +import { toast } from 'sonner'; + +type Message = { role: 'user' | 'assistant'; text: string }; + +export function FloatingChat() { + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState([ + { role: 'assistant', text: 'Hi! I am your AI Study Coach. Let\'s discuss your preparation, study plans, or any insights you need!' } + ]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages, isLoading, isOpen]); + + const handleSend = async () => { + if (!input.trim()) return; + const userMsg = input.trim(); + setInput(''); + setMessages(prev => [...prev, { role: 'user', text: userMsg }]); + setIsLoading(true); + + try { + // Pass the previous conversation (except the initial greeting if it's the only one, but sending it is fine) + const result = await sendStudyChat(userMsg, messages.slice(-5)); + if (result.error) { + toast.error(result.error); + setMessages(prev => [...prev, { role: 'assistant', text: "I'm having trouble connecting right now. Please try again." }]); + } else { + setMessages(prev => [...prev, { role: 'assistant', text: result.answer }]); + } + } catch { + toast.error('Failed to send message.'); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + + {isOpen && ( + + {/* Header */} +
+
+
+ +
+
+

Study Coach AI

+

Always here to help

+
+
+ +
+ + {/* Chat Area */} +
+ {messages.map((msg, i) => ( +
+
+ {msg.text} +
+
+ ))} + {isLoading && ( +
+
+ + Thinking... +
+
+ )} +
+ + {/* Input Area */} +
+
{ e.preventDefault(); handleSend(); }} + className="flex items-center gap-2 relative" + > + setInput(e.target.value)} + placeholder="Ask about your prep..." + className="pr-10 bg-background/50 border-border/50 rounded-full focus-visible:ring-1" + disabled={isLoading} + /> + +
+
+
+ )} +
+ + setIsOpen(!isOpen)} + className="fixed bottom-6 right-6 w-14 h-14 bg-purple-600 hover:bg-purple-700 text-white rounded-full shadow-xl flex items-center justify-center z-50 hover:shadow-purple-500/30 transition-all" + > + {isOpen ? : } + + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index fa0aecc..f21fb46 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -13,6 +13,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery'; import { BottomMobileNav } from './BottomMobileNav'; import { Footer } from './Footer'; +import { FloatingChat } from './FloatingChat'; export const Layout = () => { const { deviceType } = useDevice(); @@ -88,6 +89,8 @@ export const Layout = () => { + + ); diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 470c4a2..9102ec8 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -1,8 +1,9 @@ -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'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useSearchParams } from 'react-router-dom'; // Sub-components import { TimerSettings } from './settings/TimerSettings'; @@ -10,10 +11,19 @@ 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 [searchParams, setSearchParams] = useSearchParams(); + const currentTab = searchParams.get('tab') || 'account'; + + const handleTabChange = (value: string) => { + setSearchParams({ tab: value }); + }; + 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 }, @@ -42,7 +52,8 @@ export function Settings() { @@ -66,6 +77,10 @@ export function Settings() { + + + + diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index eed752b..29351f8 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -83,7 +83,7 @@ export function TopBar({ onMenuClick }: TopBarProps) { ]; return ( -
+
{/* LEFT — hamburger + clock */}
+ + + + ); + } + + if (!insights) return null; + + const { productivityScore, scoreBreakdown, insights: aiInsights, recommendations, summary, prepAdvice } = 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}

+
+ )} + + {prepAdvice && ( +
+

Preparation Advice

+

{prepAdvice}

+
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/RecommendationsCard.tsx b/frontend/src/components/dashboard/RecommendationsCard.tsx new file mode 100644 index 0000000..50f74b8 --- /dev/null +++ b/frontend/src/components/dashboard/RecommendationsCard.tsx @@ -0,0 +1,89 @@ +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'; +import type { LucideIcon } 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/dashboard/StudyPlanCard.tsx b/frontend/src/components/dashboard/StudyPlanCard.tsx new file mode 100644 index 0000000..5c3b73d --- /dev/null +++ b/frontend/src/components/dashboard/StudyPlanCard.tsx @@ -0,0 +1,165 @@ +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, AlertCircle, Sparkles } 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 ( + +
+
+ +
+
+ +
+ +
+

AI Study Plan

+

+ Set up your study profile to unlock a personalized, AI-powered study schedule. +

+
+ +
+ + AI insights unavailable right now +
+ +
+ + +
+
+
+ + ); + } + + 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/landing-page-modern/detailed-features/DetailedFeaturesSection.tsx b/frontend/src/components/landing-page-modern/detailed-features/DetailedFeaturesSection.tsx index 16b56f3..a49c8b8 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,8 @@ import { SkipForward, Coffee, LogOut, + MessageSquare, + Bot, } from 'lucide-react'; const detailedFeatures = [ @@ -47,6 +49,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 +85,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 +143,7 @@ export const DetailedFeaturesSection = () => { viewport={{ once: true }} >
{feat} @@ -156,7 +167,7 @@ export const DetailedFeaturesSection = () => {
{/* Focus Engine (Timer) */} - {index === 0 && ( + {feature.subtitle === 'Focus Engine' && (
{/* Glowing circular progress mask */} @@ -194,7 +205,7 @@ export const DetailedFeaturesSection = () => { )} {/* Task Manager (Kanban) */} - {index === 1 && ( + {feature.subtitle === 'Task Manager' && (
{/* Column 1 */}
@@ -228,7 +239,7 @@ export const DetailedFeaturesSection = () => { )} {/* Productivity Analytics */} - {index === 2 && ( + {feature.subtitle === 'Productivity Analytics' && (
Weekly Performance @@ -259,7 +270,7 @@ export const DetailedFeaturesSection = () => { )} {/* Spotify Control */} - {index === 3 && ( + {feature.subtitle === 'Spotify Control' && (
{/* album cover art placeholder */} @@ -301,7 +312,7 @@ export const DetailedFeaturesSection = () => { )} {/* Time Tracking */} - {index === 4 && ( + {feature.subtitle === 'Time Tracking' && (
Shift Tracker @@ -324,6 +335,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/footer/FooterSection.tsx b/frontend/src/components/landing-page-modern/footer/FooterSection.tsx index 0890423..051892c 100644 --- a/frontend/src/components/landing-page-modern/footer/FooterSection.tsx +++ b/frontend/src/components/landing-page-modern/footer/FooterSection.tsx @@ -10,7 +10,7 @@ export const FooterSection = () => {
FocusMaster Logo diff --git a/frontend/src/components/landing-page-modern/header/Header.tsx b/frontend/src/components/landing-page-modern/header/Header.tsx index bc79fbd..6ede2cc 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 */} -