diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..596c325 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..dcac166 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json package-lock.json ./ +RUN npm ci + +# Copy the rest of the application code +COPY . . + +# Start the Next.js development server +CMD ["npm", "run", "dev"] diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..18d787d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +*$py.class + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..9bbcbad --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/ai_coach.py b/backend/ai_coach.py new file mode 100644 index 0000000..3d2616f --- /dev/null +++ b/backend/ai_coach.py @@ -0,0 +1,34 @@ +from datetime import datetime, timedelta +from typing import Tuple + +def calculate_sm2(q: int, repetitions: int, ease_factor: float, previous_interval: int) -> Tuple[int, float]: + """ + Implements the standard SuperMemo-2 (SM-2) algorithm. + q: quality of the response (0-5) + 5: perfect response + 4: correct response after a hesitation + 3: correct response caught with serious difficulty + 2: incorrect response; where the correct one seemed easy to recall + 1: incorrect response; the correct one remembered + 0: complete blackout + """ + if q < 3: + # If the answer was incorrect, reset repetitions and schedule for immediate review (1 day) + return 1, ease_factor + + # Calculate new Easiness Factor + new_ef = ease_factor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)) + if new_ef < 1.3: + new_ef = 1.3 + + # Calculate next interval (in days) + if repetitions == 0: + new_interval = 1 + elif repetitions == 1: + new_interval = 3 # Calibrated for intense JEE/NEET daily sprint cycles + elif repetitions == 2: + new_interval = 7 + else: + new_interval = round(previous_interval * new_ef) + + return new_interval, new_ef diff --git a/backend/analytics.py b/backend/analytics.py new file mode 100644 index 0000000..f0e178f --- /dev/null +++ b/backend/analytics.py @@ -0,0 +1,63 @@ +from sqlmodel import Session, select +from datetime import datetime, timedelta +from collections import Counter +from uuid import UUID +import models + +def generate_parent_report(student_id: UUID, session: Session) -> dict: + """ + Aggregates granular performance data for a specific student + into a clean, simple, parent-friendly schema. + """ + # 1. Fetch Student Baseline Info + student = session.get(models.User, student_id) + if not student: + return {"error": "Student record not found."} + + # 2. Compile Last 7 Days Time & Attempts Data + seven_days_ago = datetime.utcnow() - timedelta(days=7) + attempts = session.exec( + select(models.Attempt) + .where(models.Attempt.user_id == student_id) + .where(models.Attempt.submitted_at >= seven_days_ago) + ).all() + + total_time_spent_seconds = sum((a.time_taken or 0) for a in attempts) + total_time_spent_minutes = round(total_time_spent_seconds / 60, 1) + + # 3. Calculate 7-Day Accuracy Trend Line Data + # Group accuracy by date strings for simple frontend parsing + trend_data = [] + for i in range(6, -1, -1): + target_date = (datetime.utcnow() - timedelta(days=i)).date() + daily_attempts = [a for a in attempts if a.submitted_at.date() == target_date] + + if daily_attempts: + avg_accuracy = sum(a.accuracy for a in daily_attempts) / len(daily_attempts) + trend_data.append({"date": target_date.strftime("%b %d"), "accuracy": round(avg_accuracy, 1)}) + else: + trend_data.append({"date": target_date.strftime("%b %d"), "accuracy": 0.0}) + + # 4. Extract Top "Areas of Concern" (Active un-mastered mistakes) + active_mistakes = session.exec( + select(models.Mistake) + .where(models.Mistake.user_id == student_id) + .where(models.Mistake.is_mastered == False) + ).all() + + # Aggregate topic counts to find where they struggle most + topic_counter = Counter([m.topic for m in active_mistakes]) + top_concerns = [ + {"topic": topic, "pending_errors": count} + for topic, count in topic_counter.most_common(3) + ] + + return { + "student_name": student.name, + "streak_count": student.streak_count, + "weekly_minutes_spent": total_time_spent_minutes, + "total_tests_attempted_this_week": len(attempts), + "accuracy_trend": trend_data, + "areas_of_concern": top_concerns, + "last_active": student.last_active.strftime("%Y-%m-%d %H:%M:%S") + } diff --git a/backend/billing.py b/backend/billing.py new file mode 100644 index 0000000..bc8760a --- /dev/null +++ b/backend/billing.py @@ -0,0 +1,45 @@ +import os +import hmac +import hashlib +from uuid import UUID + +# In a live environment, these are provided by the Razorpay Dashboard. +# We use mock keys for the local sandbox. +RAZORPAY_KEY_ID = os.getenv("RAZORPAY_KEY_ID", "rzp_test_local_sandbox") +RAZORPAY_KEY_SECRET = os.getenv("RAZORPAY_KEY_SECRET", "local_secret_mock") +RAZORPAY_WEBHOOK_SECRET = os.getenv("RAZORPAY_WEBHOOK_SECRET", "local_webhook_secret") + +def generate_mock_order(user_id: UUID, tier: str, amount_in_paise: int) -> dict: + """ + Simulates the Razorpay Orders API response for local testing. + Normally this would call: razorpay_client.order.create(data) + """ + import time + mock_order_id = f"order_{int(time.time())}_{str(user_id)[:8]}" + + return { + "id": mock_order_id, + "entity": "order", + "amount": amount_in_paise, + "currency": "INR", + "status": "created", + "notes": { + "user_id": str(user_id), + "tier_requested": tier + } + } + +def verify_webhook_signature(payload_body: str, signature_header: str) -> bool: + """ + Cryptographically verifies that the webhook actually came from Razorpay. + """ + if not signature_header: + return False + + expected_signature = hmac.new( + key=RAZORPAY_WEBHOOK_SECRET.encode('utf-8'), + msg=payload_body.encode('utf-8'), + digestmod=hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(expected_signature, signature_header) diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule new file mode 100644 index 0000000..a9c5eb0 Binary files /dev/null and b/backend/celerybeat-schedule differ diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..e4186cb --- /dev/null +++ b/backend/database.py @@ -0,0 +1,17 @@ +import os +from sqlmodel import create_engine, SQLModel, Session +from typing import Generator + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod") + +# Using the standard SQLModel engine config for PostgreSQL +engine = create_engine(DATABASE_URL, echo=True) + +def init_db(): + # Automatically generates tables based on the classes imported from models.py + import models + SQLModel.metadata.create_all(engine) + +def get_session() -> Generator[Session, None, None]: + with Session(engine) as session: + yield session diff --git a/backend/growth.py b/backend/growth.py new file mode 100644 index 0000000..255d43d --- /dev/null +++ b/backend/growth.py @@ -0,0 +1,36 @@ +from sqlmodel import Session, select +from uuid import UUID +import uuid +from datetime import timedelta +import models + +def generate_referral_link(user_id: UUID) -> str: + """Generates a unique shortcode for the user to share.""" + # Using a simple deterministic slice for the local sandbox + shortcode = str(user_id).split("-")[0] + return f"https://pragativeer.com/join/{shortcode}" + +def process_referral_reward(referrer_id: UUID, new_user_id: UUID, session: Session): + """ + Grants both users 7 days of AI Coach access for free as per PRD Section 3.2. + """ + referrer = session.get(models.User, referrer_id) + new_user = session.get(models.User, new_user_id) + + if referrer and new_user: + # Upgrade both to AI Coach + referrer.tier = models.TierEnum.AI_COACH + new_user.tier = models.TierEnum.AI_COACH + + # Add 7 days to their trial/subscription end dates + from datetime import datetime + now = datetime.utcnow() + + referrer.subscription_end = (referrer.subscription_end or now) + timedelta(days=7) + new_user.trial_end = now + timedelta(days=7) + + session.add(referrer) + session.add(new_user) + session.commit() + return True + return False diff --git a/backend/llm.py b/backend/llm.py new file mode 100644 index 0000000..330da22 --- /dev/null +++ b/backend/llm.py @@ -0,0 +1,55 @@ +import os +import json +from openai import AsyncOpenAI +from sqlmodel import Session, select +from fastapi.responses import StreamingResponse +from uuid import UUID + +import models + +# Point the standard AsyncOpenAI client to your local Ollama server +client = AsyncOpenAI( + base_url=os.getenv("LLM_BASE_URL", "http://host.docker.internal:11434/v1"), + api_key=os.getenv("OPENAI_API_KEY", "ollama"), # Required by the SDK, but Ollama ignores it +) + +async def stream_tutor_response(user_id: UUID, user_message: str, session: Session): + # 1. Fetch Context: Get the student's active weak areas + active_mistakes = session.exec( + select(models.Mistake) + .where(models.Mistake.user_id == user_id) + .where(models.Mistake.is_mastered == False) + ).all() + + weak_topics = list(set([m.topic for m in active_mistakes])) + context_string = ", ".join(weak_topics) if weak_topics else "General Syllabus" + + # 2. Construct the highly-specific System Prompt + system_prompt = f""" + You are PragatiVeer's elite AI Tutor for JEE & NEET aspirants. + Your goal is to guide students to the answer, not just give it to them. + + STUDENT CONTEXT: + This student currently struggles with the following topics: {context_string}. + Keep this in mind and relate explanations back to foundational concepts in these areas if applicable. + + RULES: + 1. Respond in the student's preferred language (English or Hindi). + 2. Use LaTeX wrapped in single $ for inline math and $$ for block equations. + 3. Keep responses concise and focused on problem-solving steps. + """ + + # 3. Call the LLM + response = await client.chat.completions.create( + model=os.getenv("LLM_MODEL_NAME", "gemma"), + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message} + ], + stream=True, + ) + + # 4. Yield tokens as they arrive + async for chunk in response: + if chunk.choices[0].delta.content is not None: + yield chunk.choices[0].delta.content diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..f20490c --- /dev/null +++ b/backend/main.py @@ -0,0 +1,490 @@ +from fastapi import FastAPI, Depends, HTTPException, status, Request +from fastapi.responses import StreamingResponse +from fastapi.middleware.cors import CORSMiddleware +from sqlmodel import Session, select +from contextlib import asynccontextmanager +from typing import List, Dict, Any, Optional +from uuid import UUID +from datetime import datetime, timedelta +from pydantic import BaseModel + +from database import init_db, get_session +import models +from ai_coach import calculate_sm2 +from analytics import generate_parent_report +from billing import generate_mock_order, verify_webhook_signature +from llm import stream_tutor_response +from analytics import generate_parent_report +from predictor import calculate_readiness_score +from growth import generate_referral_link, process_referral_reward + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Initialize database tables locally when the container boots up + init_db() + yield + +app = FastAPI( + title="PragatiVeer Core Engine", + version="1.0.0", + lifespan=lifespan +) + +# Enable immediate communication with your Next.js frontend local environment +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.get("/health", tags=["System"]) +def health_check(): + return {"status": "operational", "engine": "FastAPI Async", "environment": "local_dev"} + +# --- ACCESS CONTROL DEPENDENCY --- + +def require_tier(allowed_tiers: List[models.TierEnum]): + """ + Dependency to block unauthorized access to premium routes. + """ + def tier_checker(user_id: UUID, session: Session = Depends(get_session)): + user = session.get(models.User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found.") + + if user.tier not in allowed_tiers: + raise HTTPException( + status_code=403, + detail=f"Access denied. Requires one of: {[t.value for t in allowed_tiers]}" + ) + return user + return tier_checker + +# --- BASELINE AUTHENTICATION MOCKS FOR FRONTEND UI VERIFICATION --- + +@app.post("/api/auth/otp-request", tags=["Authentication"]) +def request_otp(phone_or_email: str): + # Mocking execution loop for user onboarding flow + return {"message": "Verification code dispatched successfully", "debug_otp": "123456"} + +@app.post("/api/auth/otp-verify", tags=["Authentication"]) +def verify_otp(phone_or_email: str, code: str, session: Session = Depends(get_session)): + # Add your logic matching the registration & profile onboarding flow later + return {"status": "authenticated", "token": "mock_jwt_session_token_string"} + +# --- CBT TEST ENVIRONMENT CONTENT DELIVERY ENDPOINTS --- + +@app.get("/api/tests/{test_id}", response_model=models.Test, tags=["CBT Engine"]) +def get_test_by_id(test_id: UUID, user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.PRO, models.TierEnum.AI_COACH]))): + test = session.get(models.Test, test_id) + if not test: + raise HTTPException(status_code=404, detail="Requested examination structure not found.") + return test + +@app.get("/api/questions/{question_id}", response_model=models.Question, tags=["CBT Engine"]) +def get_question_by_id(question_id: UUID, session: Session = Depends(get_session)): + question = session.get(models.Question, question_id) + if not question: + raise HTTPException(status_code=404, detail="Requested question not found.") + return question + +@app.post("/api/attempts/submit", response_model=models.Attempt, tags=["CBT Engine"]) +def submit_test_attempt(payload: Dict[str, Any], session: Session = Depends(get_session)): + user_id = payload.get("user_id") + source_type = payload.get("source_type") + source_id = payload.get("source_id") + client_answers = payload.get("answers", []) # List of dicts: {"question_id": "...", "selected_answer": "..."} + + if not user_id or not source_id: + raise HTTPException(status_code=400, detail="Missing required deployment metadata parameters.") + + # 0. Enforce Timer Integrity: Verify that the test window is still active + if source_type == "Test": + test = session.get(models.Test, source_id) + if test and test.scheduled_date: + # Removed strict time cutoff for development/testing + pass + + # 1. Initialize structural baseline metrics + total_questions = len(client_answers) + correct_count = 0 + processed_answers = [] + + # Extract pre-generated attempt ID or create a new one + attempt_id_str = payload.get("id") + import uuid + attempt_id = uuid.UUID(attempt_id_str) if attempt_id_str else uuid.uuid4() + + # 2. Secure Server-Side Evaluation Loop + for item in client_answers: + q_id = item.get("question_id") + selected = item.get("selected_answer") + + # Fetch actual answer key securely from PostgreSQL (Do not trust frontend state values) + question = session.get(models.Question, q_id) + if not question: + continue + + # Check correctness against JSON database configurations + is_correct = question.correct_answer.get("correct") == selected + if is_correct: + correct_count += 1 + + processed_answers.append({ + "question_id": str(q_id), + "selected_answer": selected, + "is_correct": is_correct, + "time_spent": item.get("time_spent", 0) + }) + + # 3. AI Coach Processing: If the answer is incorrect, extract it to the Mistake queue + if not is_correct: + # Check if this user has failed this specific question before to track execution loops + existing_mistake = session.exec( + select(models.Mistake).where( + models.Mistake.user_id == user_id, + models.Mistake.question_id == q_id + ) + ).first() + + if existing_mistake: + # User failed it again: reset repetitions, assign low quality score (q=1) + interval, ef = calculate_sm2(q=1, repetitions=existing_mistake.review_count, ease_factor=2.5, previous_interval=1) + existing_mistake.review_count += 1 + existing_mistake.next_review = datetime.utcnow() + timedelta(days=interval) + existing_mistake.consecutive_correct = 0 + session.add(existing_mistake) + else: + # Brand new mistake: enter into the spaced repetition queue for the first time + new_mistake = models.Mistake( + user_id=user_id, + question_id=q_id, + attempt_id=attempt_id, # Linking back to original source file attempt mapping + error_type=models.ErrorTypeEnum.CONCEPTUAL, # Defaults to conceptual until AI tags it + topic=question.topic, + subtopic=question.subtopic, + next_review=datetime.utcnow() + timedelta(days=1), # Schedule for tomorrow review loop + review_count=1, + consecutive_correct=0, + is_mastered=False + ) + session.add(new_mistake) + + # 4. Save Core Attempt Records + accuracy = (correct_count / total_questions) * 100 if total_questions > 0 else 0.0 + + attempt_record = models.Attempt( + id=attempt_id, + user_id=user_id, + source_type=source_type, + source_id=source_id, + started_at=datetime.utcnow() - timedelta(seconds=payload.get("time_taken", 0)), + submitted_at=datetime.utcnow(), + time_taken=payload.get("time_taken", 0), + answers=processed_answers, + score=correct_count, + total_questions=total_questions, + accuracy=accuracy, + status="Submitted" + ) + + session.add(attempt_record) + session.commit() + session.refresh(attempt_record) + + return attempt_record + +# --- AI COACH SPАСED REPETITION ENDPOINTS --- + +@app.get("/api/mistakes/today", tags=["AI Coach"]) +def get_todays_mistakes(user_id: UUID, session: Session = Depends(get_session)): + # Retrieve all un-mastered mistakes for the user scheduled on or before today + now = datetime.utcnow() + statement = select(models.Mistake, models.Question).join( + models.Question, models.Mistake.question_id == models.Question.id + ).where( + models.Mistake.user_id == user_id, + models.Mistake.is_mastered == False, + models.Mistake.next_review <= now + ) + results = session.exec(statement).all() + + response_data = [] + for mistake, question in results: + response_data.append({ + "mistake": mistake, + "question": question + }) + return response_data + +@app.post("/api/mistakes/review", tags=["AI Coach"]) +def submit_mistake_review(payload: Dict[str, Any], session: Session = Depends(get_session)): + mistake_id = payload.get("mistake_id") + selected_answer = payload.get("selected_answer") + + if not mistake_id or not selected_answer: + raise HTTPException(status_code=400, detail="Missing mistake_id or selected_answer") + + mistake = session.get(models.Mistake, mistake_id) + if not mistake: + raise HTTPException(status_code=404, detail="Mistake not found.") + + question = session.get(models.Question, mistake.question_id) + + is_correct = question.correct_answer.get("correct") == selected_answer + + # Calculate intervals + q_value = 4 if is_correct else 1 + + # Very basic interval calculation for demo + days_since_creation = (datetime.utcnow() - mistake.created_at).days + prev_interval = max(1, days_since_creation) + + interval, ef = calculate_sm2(q=q_value, repetitions=mistake.review_count, ease_factor=2.5, previous_interval=prev_interval) + + if is_correct: + mistake.consecutive_correct += 1 + if mistake.consecutive_correct >= 3: + mistake.is_mastered = True + else: + mistake.consecutive_correct = 0 + + mistake.review_count += 1 + mistake.next_review = datetime.utcnow() + timedelta(days=interval) + + session.add(mistake) + session.commit() + session.refresh(mistake) + + return {"mistake": mistake, "is_correct": is_correct, "correct_answer": question.correct_answer.get("correct")} + +# --- PARENT SUBSYSTEM ENDPOINTS --- + +@app.post("/api/parent/register", response_model=models.Parent, tags=["Parent Subsystem"]) +def register_parent(parent_data: models.Parent, session: Session = Depends(get_session)): + # Persist parent baseline identity profile + session.add(parent_data) + session.commit() + session.refresh(parent_data) + return parent_data + +@app.post("/api/parent/link-student", tags=["Parent Subsystem"]) +def link_student_to_parent(parent_id: UUID, student_phone_or_email: str, session: Session = Depends(get_session)): + parent = session.get(models.Parent, parent_id) + if not parent: + raise HTTPException(status_code=404, detail="Parent account context not found.") + + # Locate the target student account by phone or email criteria + student = session.exec( + select(models.User).where( + (models.User.phone == student_phone_or_email) | + (models.User.email == student_phone_or_email) + ) + ).first() + + if not student: + raise HTTPException(status_code=404, detail="Target student profile could not be verified.") + + # Append the student's UUID string to the parent's linked array + current_links = list(parent.linked_students or []) + if str(student.id) not in current_links: + current_links.append(str(student.id)) + parent.linked_students = current_links + session.add(parent) + session.commit() + + return {"message": "Student successfully linked to parent context.", "linked_student_id": student.id} + +@app.get("/api/parent/{parent_id}/dashboard/{student_id}", tags=["Parent Subsystem"]) +def get_parent_dashboard_metrics(parent_id: UUID, student_id: UUID, session: Session = Depends(get_session)): + parent = session.get(models.Parent, parent_id) + if not parent: + raise HTTPException(status_code=404, detail="Unauthorized parent profile access attempt.") + + # Security check: Ensure this parent is explicitly authorized to view this student's data + if str(student_id) not in (parent.linked_students or []): + raise HTTPException(status_code=403, detail="Access denied to requested student dashboard data.") + + report = generate_parent_report(student_id, session) + return report + + + +# --- BILLING & SUBSCRIPTION ENDPOINTS --- + +@app.post("/api/billing/create-order", tags=["Subscription Engine"]) +def create_subscription_order(user_id: UUID, plan_tier: str, session: Session = Depends(get_session)): + user = session.get(models.User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found.") + + # Match PRD pricing parameters (amounts in paise) + pricing_map = { + "Pro": 29900, # ₹299/mo + "Al_Coach": 59900 # ₹599/mo + } + + amount = pricing_map.get(plan_tier) + if not amount: + raise HTTPException(status_code=400, detail="Invalid subscription tier requested.") + + order = generate_mock_order(user_id, plan_tier, amount) + return {"order_id": order["id"], "amount": amount, "currency": "INR"} + +@app.post("/api/billing/webhook/razorpay", tags=["Subscription Engine"]) +async def razorpay_webhook_handler(request: Request, session: Session = Depends(get_session)): + """ + Receives server-to-server notifications when a payment succeeds. + """ + payload_bytes = await request.body() + payload_str = payload_bytes.decode('utf-8') + signature = request.headers.get("X-Razorpay-Signature", "") + + # Security Check: Verify signature + if not verify_webhook_signature(payload_str, signature): + raise HTTPException(status_code=400, detail="Invalid webhook signature.") + + import json + data = json.loads(payload_str) + + # Process the successful payment event + if data.get("event") == "payment.captured": + notes = data["payload"]["payment"]["entity"]["notes"] + user_id = notes.get("user_id") + tier_requested = notes.get("tier_requested") + + user = session.get(models.User, user_id) + if user and tier_requested: + # Upgrade the user's tier in the database + user.tier = models.TierEnum(tier_requested) + session.add(user) + + # Log the subscription record + from datetime import timedelta + new_sub = models.Subscription( + user_id=user.id, + tier=models.TierEnum(tier_requested), + billing_cycle="Monthly", + start_date=datetime.utcnow(), + end_date=datetime.utcnow() + timedelta(days=30), + amount=data["payload"]["payment"]["entity"]["amount"], + payment_id=data["payload"]["payment"]["entity"]["id"], + status="Active" + ) + session.add(new_sub) + session.commit() + + return {"status": "Webhook processed successfully"} + +# --- STUDY PLAN ENDPOINTS --- +from datetime import date + +@app.get("/api/ai-coach/study-plan/today", tags=["AI Coach"]) +def get_todays_study_plan(user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.AI_COACH]))): + today = datetime.utcnow().date() + plan = session.exec( + select(models.StudyPlan) + .where(models.StudyPlan.user_id == user_id) + .where(models.StudyPlan.date == today) + ).first() + + if not plan: + raise HTTPException(status_code=404, detail="No study plan generated for today yet.") + + return plan + +class ChatRequest(BaseModel): + user_id: UUID + message: str + +@app.post("/api/ai-coach/chat/stream", tags=["AI Coach"]) +async def ai_tutor_chat_stream(request: ChatRequest, session: Session = Depends(get_session)): + """ + Streams the context-aware LLM response back to the client. + """ + user = session.get(models.User, request.user_id) + if not user: + raise HTTPException(status_code=404, detail="Student profile not found.") + if user.tier != models.TierEnum.AI_COACH: + raise HTTPException(status_code=403, detail="Access denied. Gated to AI_COACH tier.") + + # Enforce Daily Limit (50 messages) as per PRD A-04 + # (In a full production build, you'd increment a Redis counter here) + + return StreamingResponse( + stream_tutor_response(request.user_id, request.message, session), + media_type="text/event-stream" + ) + +@app.get("/api/ai-coach/readiness/{user_id}", tags=["AI Coach"]) +def get_exam_readiness(user_id: UUID, session: Session = Depends(get_session), current_user = Depends(require_tier([models.TierEnum.AI_COACH]))): + """ + Returns the dynamic 0-100 Exam Readiness Score and College Prediction. + """ + readiness_data = calculate_readiness_score(user_id, session) + if not readiness_data: + raise HTTPException(status_code=404, detail="Could not calculate readiness score.") + + return readiness_data + +@app.get("/api/growth/referral/{user_id}", tags=["Viral Growth"]) +def get_referral_dashboard(user_id: UUID, session: Session = Depends(get_session)): + user = session.get(models.User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found.") + + link = generate_referral_link(user_id) + + return { + "referral_link": link, + "total_referrals": 0, # In production, query a referral tracking table + "rewards_earned_days": 0, + "share_message": f"I'm on a {user.streak_count}-day study streak on PragatiVeer! Use my link to get 7 days of the AI Coach for free: {link}" + } + +# --- USER CRUD & TEST LISTING --- +@app.get("/api/users/{user_id}", response_model=models.User, tags=["Users"]) +def get_user_profile(user_id: UUID, session: Session = Depends(get_session)): + user = session.get(models.User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + +class UserUpdate(BaseModel): + name: Optional[str] = None + target_exam: Optional[models.ExamEnum] = None + student_class: Optional[models.ClassEnum] = None + +@app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"]) +def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session)): + user = session.get(models.User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if payload.name is not None: + user.name = payload.name + if payload.target_exam is not None: + user.target_exam = payload.target_exam + if payload.student_class is not None: + # Note: mapping alias 'class' in User class to 'student_class' + user.student_class = payload.student_class + + session.add(user) + session.commit() + session.refresh(user) + return user + +@app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"]) +def list_tests(exam_pattern: Optional[models.ExamEnum] = None, session: Session = Depends(get_session)): + query = select(models.Test) + if exam_pattern is not None: + query = query.where(models.Test.exam_pattern == exam_pattern) + return session.exec(query).all() + +@app.get("/api/users/{user_id}/attempts", tags=["Users"]) +def get_user_attempts(user_id: UUID, session: Session = Depends(get_session)): + query = select(models.Attempt).where(models.Attempt.user_id == user_id) + return session.exec(query).all() + diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..9a5a98d --- /dev/null +++ b/backend/models.py @@ -0,0 +1,168 @@ +from datetime import datetime, date +from enum import Enum +from typing import List, Optional, Dict, Any +from uuid import UUID, uuid4 +from sqlmodel import Field, SQLModel, Relationship, JSON + +# --- ENUMS --- +class ClassEnum(str, Enum): + ELEVENTH = "11" + TWELFTH = "12" + DROPPER = "Dropper" + +class ExamEnum(str, Enum): + JEE_MAIN = "JEE_Main" + JEE_ADVANCED = "JEE_Advanced" + NEET = "NEET" + +class MediumEnum(str, Enum): + ENGLISH = "English" + HINDI = "Hindi" + BILINGUAL = "Bilingual" + +class TierEnum(str, Enum): + FREE = "Free" + PRO = "Pro" + AI_COACH = "Al_Coach" + +class QuestionTypeEnum(str, Enum): + MCQ_SINGLE = "MCQ_Single" + MCQ_MULTIPLE = "MCQ_Multiple" + NUMERICAL = "Numerical" + +class SourceTypeEnum(str, Enum): + DPS = "DPS" + TEST = "Test" + MICROTEST = "MicroTest" + +class ErrorTypeEnum(str, Enum): + CONCEPTUAL = "Conceptual" + CALCULATION = "Calculation" + READING = "Reading" + TIME_PRESSURE = "Time_Pressure" + +# --- CORE DB MODELS --- + +class User(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + email: Optional[str] = Field(default=None, unique=True) + phone: Optional[str] = Field(default=None, unique=True) + password_hash: str + name: str + student_class: ClassEnum = Field(alias="class") + target_exam: ExamEnum + medium: MediumEnum + school: Optional[str] = None + city: str + state: str + tier: TierEnum = Field(default=TierEnum.FREE) + trial_end: Optional[datetime] = None + subscription_end: Optional[datetime] = None + streak_count: int = Field(default=0) + longest_streak: int = Field(default=0) + created_at: datetime = Field(default_factory=datetime.utcnow) + last_active: datetime = Field(default_factory=datetime.utcnow) + +class Parent(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + email: str + phone: str + password_hash: str + name: str + language: str = Field(default="English") # e.g., Hindi, English + linked_students: List[str] = Field(default=[], sa_type=JSON) + created_at: datetime = Field(default_factory=datetime.utcnow) + +class Question(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + subject: str # Physics, Chemistry, Maths, Biology + topic: str + subtopic: str + difficulty: str # Easy, Medium, Hard + type: QuestionTypeEnum + question_text: str + question_text_hi: Optional[str] = None + options: Optional[Dict[str, Any]] = Field(default=None, sa_type=JSON) + options_hi: Optional[Dict[str, Any]] = Field(default=None, sa_type=JSON) + correct_answer: Dict[str, Any] = Field(sa_type=JSON) + solution_text: str + solution_text_hi: Optional[str] = None + language: MediumEnum + year: Optional[int] = None + exam: Optional[ExamEnum] = None + tags: Optional[str] = None + is_active: bool = Field(default=True) + created_by: UUID + verified_by: Optional[UUID] = None + +class DPS(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + date: date + title: str + questions: List[str] = Field(default=[], sa_type=JSON) # Array of question UUID strings + difficulty: str + time_limit: int + is_active: bool = Field(default=True) + created_by: UUID + +class Test(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + title: str + type: str # Weekly, Mock, PYQ + exam_pattern: ExamEnum + sections: Dict[str, Any] = Field(sa_type=JSON) # Array of {name, questions[], time_limit} + total_time: int + difficulty: str + scheduled_date: Optional[datetime] = None + is_active: bool = Field(default=True) + created_by: UUID + +class Attempt(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + user_id: UUID = Field(foreign_key="user.id") + source_type: SourceTypeEnum + source_id: UUID + started_at: datetime + submitted_at: Optional[datetime] = None + time_taken: Optional[int] = None # in seconds + answers: Optional[Dict[str, Any]] = Field(default=None, sa_type=JSON) # Array of {question_id, selected_answer, is_correct, time_spent} + score: int = Field(default=0) + total_questions: int + accuracy: float = Field(default=0.0) + status: str = Field(default="In_Progress") # In_Progress, Submitted, Timed_Out + +class Mistake(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + user_id: UUID = Field(foreign_key="user.id") + question_id: UUID = Field(foreign_key="question.id") + attempt_id: UUID = Field(foreign_key="attempt.id") + error_type: ErrorTypeEnum + topic: str + subtopic: str + next_review: datetime + review_count: int = Field(default=0) + consecutive_correct: int = Field(default=0) + is_mastered: bool = Field(default=False) + created_at: datetime = Field(default_factory=datetime.utcnow) + +class StudyPlan(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + user_id: UUID = Field(foreign_key="user.id") + date: date + tasks: Dict[str, Any] = Field(sa_type=JSON) # Array of {type, description, duration_minutes, status} + is_completed: bool = Field(default=False) + generated_at: datetime = Field(default_factory=datetime.utcnow) + feedback: Optional[str] = None + +class Subscription(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + user_id: UUID = Field(foreign_key="user.id") + tier: TierEnum + billing_cycle: str # Monthly, Quarterly, Annual + start_date: datetime + end_date: datetime + amount: int # Stored in Paise (INR) + payment_id: str # Razorpay order ID / payment ID + status: str # Active, Expired, Cancelled, Paused + auto_renew: bool = Field(default=True) + coupon_code: Optional[str] = None diff --git a/backend/predictor.py b/backend/predictor.py new file mode 100644 index 0000000..eedce53 --- /dev/null +++ b/backend/predictor.py @@ -0,0 +1,76 @@ +from sqlmodel import Session, select +from uuid import UUID +import models + +def calculate_readiness_score(user_id: UUID, session: Session) -> dict: + """ + Calculates the Exam Readiness Score (0-100) based on PRD A-05 parameters. + Weights: Accuracy (40%), Speed (20%), Syllabus Coverage (20%), + Consistency (10%), Mistake Recovery (10%). + """ + user = session.get(models.User, user_id) + if not user: + return {} + + attempts = session.exec( + select(models.Attempt).where(models.Attempt.user_id == user_id) + ).all() + + if not attempts: + return {"score": 0, "status": "Not enough data"} + + # 1. Accuracy (40% Weight) + avg_accuracy = sum(a.accuracy for a in attempts) / len(attempts) + w_accuracy = (avg_accuracy / 100) * 40 + + # 2. Speed (20% Weight) - Assuming benchmark is 120s per question + time_per_q = [ (a.time_taken / a.total_questions) for a in attempts if a.total_questions > 0 and a.time_taken ] + avg_time = sum(time_per_q) / len(time_per_q) if time_per_q else 120 + speed_factor = min(1.0, 120 / max(avg_time, 1)) # Cap at 1.0 + w_speed = speed_factor * 20 + + # 3. Consistency (10% Weight) + streak = min(user.streak_count, 30) # Max consistency score hit at 30 days + w_consistency = (streak / 30) * 10 + + # 4. Mistake Recovery (10% Weight) + mistakes = session.exec(select(models.Mistake).where(models.Mistake.user_id == user_id)).all() + mastered_count = sum(1 for m in mistakes if m.is_mastered) + total_mistakes = max(len(mistakes), 1) + w_recovery = (mastered_count / total_mistakes) * 10 + + # 5. Syllabus Coverage (20% Weight) + # For local sandbox, we will estimate based on total questions attempted + total_q_attempted = sum(a.total_questions for a in attempts) + coverage_factor = min(total_q_attempted / 500, 1.0) # Assume 500 questions = decent baseline coverage + w_coverage = coverage_factor * 20 + + # Total Calculation + total_score = round(w_accuracy + w_speed + w_consistency + w_recovery + w_coverage) + + # Calculate College Prediction based on PRD A-07 + prediction = "Keep Practicing" + target_type = "State Level" + + if total_score >= 85: + prediction = "IIT Bombay / IIT Delhi within reach!" + target_type = "Reach" + elif total_score >= 70: + prediction = "NIT Trichy / NIT Surathkal on track." + target_type = "Target" + elif total_score >= 50: + prediction = "Top GFTIs / State Engineering Colleges." + target_type = "Safety" + + return { + "score": total_score, + "breakdown": { + "accuracy": round(w_accuracy, 1), + "speed": round(w_speed, 1), + "consistency": round(w_consistency, 1), + "recovery": round(w_recovery, 1), + "coverage": round(w_coverage, 1) + }, + "college_prediction": prediction, + "prediction_category": target_type + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..de97ce3 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.110.0 +uvicorn[standard]==0.28.0 +sqlmodel==0.0.16 +psycopg2-binary==2.9.9 +redis==5.0.3 +pydantic==2.6.4 +pydantic-settings==2.2.1 +bcrypt==4.1.2 +pyjwt==2.8.0 +celery==5.3.6 +openai==1.14.0 +httpx==0.27.2 diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..a4f9722 --- /dev/null +++ b/backend/seed.py @@ -0,0 +1,317 @@ +from sqlmodel import Session, create_engine, select +from uuid import uuid4, UUID +from datetime import datetime +import os + +from database import engine, init_db +import models + +def seed_local_database(): + # Ensure tables are created + init_db() + + with Session(engine) as session: + # Prevent double seeding in local testing environment + if session.exec(select(models.Question)).first() is not None: + print("Local environment database already contains data components. Dropping all and reseeding...") + import sqlmodel + sqlmodel.SQLModel.metadata.drop_all(engine) + init_db() + + print("Seeding baseline content entities...") + + # 0. Create the baseline student user (seeded target: JEE_MAIN) + student_id = UUID("b0e17378-864d-439e-8806-708dc48a891b") + student_user = models.User( + id=student_id, + phone="9999999999", + email="student@pragativeer.com", + password_hash="mock_hash", + name="Rahul Sharma", + student_class=models.ClassEnum.TWELFTH, + medium=models.MediumEnum.ENGLISH, + target_exam=models.ExamEnum.JEE_MAIN, + city="New Delhi", + state="Delhi", + tier=models.TierEnum.AI_COACH, + streak_count=5, + last_active=datetime.utcnow() + ) + session.add(student_user) + + admin_id = uuid4() + + jee_test_id = UUID("b6faa295-3bf5-4de2-a587-d72b889629c4") + neet_test_id = UUID("e7c80521-825b-4c07-889a-ee646c05d762") + + def generate_dummy_questions(subject: str, count: int, exam: models.ExamEnum, admin_id: UUID, start_idx: int = 1): + qs = [] + for i in range(start_idx, count + start_idx): + q = models.Question( + id=uuid4(), + subject=subject, + topic=f"Generic Topic {i}", + subtopic=f"Subtopic {i}", + difficulty="Medium", + type=models.QuestionTypeEnum.MCQ_SINGLE, + question_text=f"This is a simulated {exam.value} {subject} question #{i} for mock exam scaling.", + question_text_hi=f"यह एक अनुरूपित {exam.value} {subject} प्रश्न #{i} है।", + options={"A": "Option A", "B": "Option B", "C": "Option C", "D": "Option D"}, + options_hi={"A": "विकल्प A", "B": "विकल्प B", "C": "विकल्प C", "D": "विकल्प D"}, + correct_answer={"correct": "A"}, + solution_text="Simulated solution for the mock test.", + solution_text_hi="मॉक टेस्ट के लिए अनुरूपित समाधान।", + language=models.MediumEnum.BILINGUAL, + year=2024, + exam=exam, + created_by=admin_id + ) + qs.append(q) + return qs + + # --- JEE QUESTIONS --- + + # Physics 1 + jq1 = models.Question( + id=uuid4(), + subject="Physics", + topic="Electrostatics", + subtopic="Coulomb's Law", + difficulty="Medium", + type=models.QuestionTypeEnum.MCQ_SINGLE, + question_text="Two point charges placed at a distance $r$ in air exert a force $F$ on each other. At what distance will they exert the same force if placed in a medium of dielectric constant $K=4$?", + question_text_hi="वायु में $r$ दूरी पर स्थित दो बिंदु आवेश एक दूसरे पर $F$ बल लगाते हैं। यदि उन्हें $K=4$ परावैद्युतांक वाले माध्यम में रखा जाए, तो वे किस दूरी पर समान बल लगाएंगे?", + options={"A": "$r$", "B": "$r/2$", "C": "$2r$", "D": "$r/4$"}, + options_hi={"A": "$r$", "B": "$r/2$", "C": "$2r$", "D": "$r/4$"}, + correct_answer={"correct": "B"}, + solution_text="Force in medium $F' = \\frac{F}{K}$. To maintain same force, $r' = \\frac{r}{\\sqrt{K}} = \\frac{r}{\\sqrt{4}} = \\frac{r}{2}$.", + solution_text_hi="माध्यम में बल $F' = \\frac{F}{K}$। समान बल बनाए रखने के लिए, $r' = \\frac{r}{\\sqrt{K}} = \\frac{r}{\\sqrt{4}} = \\frac{r}{2}$।", + language=models.MediumEnum.BILINGUAL, + year=2024, + exam=models.ExamEnum.JEE_MAIN, + created_by=admin_id + ) + + # Physics 2 + jq2 = models.Question( + id=uuid4(), + subject="Physics", + topic="Mechanics", + subtopic="Rotational Motion", + difficulty="Hard", + type=models.QuestionTypeEnum.MCQ_SINGLE, + question_text="A solid cylinder of mass $M$ and radius $R$ is rolling without slipping down an inclined plane of angle $\\theta$. What is the minimum coefficient of static friction $\\mu_s$ required to prevent slipping?", + question_text_hi="द्रव्यमान $M$ और त्रिज्या $R$ का एक ठोस बेलन कोण $\\theta$ के एक झुके हुए तल पर बिना फिसले लुढ़क रहा है। फिसलने से रोकने के लिए आवश्यक न्यूनतम स्थैतिक घर्षण गुणांक $\\mu_s$ क्या है?", + options={"A": "$\\frac{1}{3} \\tan \\theta$", "B": "$\\frac{2}{3} \\tan \\theta$", "C": "$\\frac{1}{2} \\tan \\theta$", "D": "$\\tan \\theta$"}, + options_hi={"A": "$\\frac{1}{3} \\tan \\theta$", "B": "$\\frac{2}{3} \\tan \\theta$", "C": "$\\frac{1}{2} \\tan \\theta$", "D": "$\\tan \\theta$"}, + correct_answer={"correct": "A"}, + solution_text="$a = \\frac{g \\sin \\theta}{1 + \\frac{k^2}{R^2}}$. For solid cylinder $k^2 = \\frac{R^2}{2}$. So $a = \\frac{2}{3} g \\sin \\theta$. Friction $f = Mg \\sin \\theta - Ma = \\frac{1}{3} Mg \\sin \\theta$. Since $f \\le \\mu_s Mg \\cos \\theta$, $\\mu_s \\ge \\frac{1}{3} \\tan \\theta$.", + solution_text_hi="$a = \\frac{g \\sin \\theta}{1 + \\frac{k^2}{R^2}}$। ठोस बेलन के लिए $k^2 = \\frac{R^2}{2}$। इसलिए $a = \\frac{2}{3} g \\sin \\theta$। घर्षण $f = Mg \\sin \\theta - Ma = \\frac{1}{3} Mg \\sin \\theta$। चूंकि $f \\le \\mu_s Mg \\cos \\theta$, $\\mu_s \\ge \\frac{1}{3} \\tan \\theta$ है।", + language=models.MediumEnum.BILINGUAL, + year=2023, + exam=models.ExamEnum.JEE_MAIN, + created_by=admin_id + ) + + # Chemistry 1 + jq3 = models.Question( + id=uuid4(), + subject="Chemistry", + topic="Physical Chemistry", + subtopic="Chemical Kinetics", + difficulty="Medium", + type=models.QuestionTypeEnum.MCQ_SINGLE, + question_text="For a first-order reaction, the time required for $99\\%$ completion is $x$ times the time required for $90\\%$ completion. The value of $x$ is:", + question_text_hi="एक प्रथम-कोटि की अभिक्रिया के लिए, $99\\%$ पूर्ण होने में लगा समय $90\\%$ पूर्ण होने में लगे समय का $x$ गुना है। $x$ का मान है:", + options={"A": "$2$", "B": "$10$", "C": "$3$", "D": "$4$"}, + options_hi={"A": "$2$", "B": "$10$", "C": "$3$", "D": "$4$"}, + correct_answer={"correct": "A"}, + solution_text="$t = \\frac{2.303}{k} \\log \\frac{100}{100-x}$. $t_{99} = \\frac{2.303}{k} \\log 100 = 2 \\times \\frac{2.303}{k}$. $t_{90} = \\frac{2.303}{k} \\log 10 = 1 \\times \\frac{2.303}{k}$. Therefore $x = 2$.", + solution_text_hi="$t = \\frac{2.303}{k} \\log \\frac{100}{100-x}$। $t_{99} = \\frac{2.303}{k} \\log 100 = 2 \\times \\frac{2.303}{k}$। $t_{90} = \\frac{2.303}{k} \\log 10 = 1 \\times \\frac{2.303}{k}$। इसलिए $x = 2$ है।", + language=models.MediumEnum.BILINGUAL, + year=2024, + exam=models.ExamEnum.JEE_MAIN, + created_by=admin_id + ) + + # Maths 1 + jq4 = models.Question( + id=uuid4(), + subject="Mathematics", + topic="Calculus", + subtopic="Definite Integration", + difficulty="Hard", + type=models.QuestionTypeEnum.MCQ_SINGLE, + question_text="The value of the integral $\\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$ is:", + question_text_hi="समाकल $\\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$ का मान है:", + options={"A": "$\\pi/2$", "B": "$\\pi/4$", "C": "$0$", "D": "$\\pi/8$"}, + options_hi={"A": "$\\pi/2$", "B": "$\\pi/4$", "C": "$0$", "D": "$\\pi/8$"}, + correct_answer={"correct": "B"}, + solution_text="Let $I = \\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$. Using $\\int_0^a f(x)dx = \\int_0^a f(a-x)dx$, we get $I = \\int_{0}^{\\pi/2} \\frac{\\cos^3 x}{\\cos^3 x + \\sin^3 x} dx$. Adding both, $2I = \\int_{0}^{\\pi/2} 1 dx = \\pi/2$. Thus $I = \\pi/4$.", + solution_text_hi="माना $I = \\int_{0}^{\\pi/2} \\frac{\\sin^3 x}{\\sin^3 x + \\cos^3 x} dx$। गुणधर्म $\\int_0^a f(x)dx = \\int_0^a f(a-x)dx$ का उपयोग करने पर, $I = \\int_{0}^{\\pi/2} \\frac{\\cos^3 x}{\\cos^3 x + \\sin^3 x} dx$। दोनों को जोड़ने पर, $2I = \\int_{0}^{\\pi/2} 1 dx = \\pi/2$। इसलिए $I = \\pi/4$ है।", + language=models.MediumEnum.BILINGUAL, + year=2023, + exam=models.ExamEnum.JEE_MAIN, + created_by=admin_id + ) + + jee_physics_dummies = generate_dummy_questions("Physics", 28, models.ExamEnum.JEE_MAIN, admin_id, start_idx=3) + jee_chem_dummies = generate_dummy_questions("Chemistry", 29, models.ExamEnum.JEE_MAIN, admin_id, start_idx=2) + jee_math_dummies = generate_dummy_questions("Mathematics", 29, models.ExamEnum.JEE_MAIN, admin_id, start_idx=2) + + session.add_all([jq1, jq2, jq3, jq4]) + session.add_all(jee_physics_dummies) + session.add_all(jee_chem_dummies) + session.add_all(jee_math_dummies) + + # --- NEET QUESTIONS --- + + # Biology 1 + nq1 = models.Question( + id=uuid4(), + subject="Botany", + topic="Genetics", + subtopic="Translation", + difficulty="Easy", + type=models.QuestionTypeEnum.MCQ_SINGLE, + question_text="Which of the following codons codes for Methionine and also acts as an initiator codon?", + question_text_hi="निम्नलिखित में से कौन सा कोडॉन मेथियोनिन के लिए कोड करता है और एक प्रारंभक कोडॉन के रूप में भी कार्य करता है?", + options={"A": "AUG", "B": "GUG", "C": "UAA", "D": "UAG"}, + options_hi={"A": "AUG", "B": "GUG", "C": "UAA", "D": "UAG"}, + correct_answer={"correct": "A"}, + solution_text="AUG is the starting codon (initiator codon) in eukaryotes and prokaryotes and also codes for the amino acid methionine.", + solution_text_hi="AUG यूकेरियोट्स और प्रोकैरियोट्स में प्रारंभिक कोडॉन (प्रारंभक कोडॉन) है और अमीनो एसिड मेथियोनिन के लिए भी कोड करता है।", + language=models.MediumEnum.BILINGUAL, + year=2024, + exam=models.ExamEnum.NEET, + created_by=admin_id + ) + + # Physics 1 (NEET) + nq2 = models.Question( + id=uuid4(), + subject="Physics", + topic="Kinematics", + subtopic="Motion under Gravity", + difficulty="Medium", + type=models.QuestionTypeEnum.MCQ_SINGLE, + question_text="A ball is thrown vertically upward with a velocity of $20\\text{ m/s}$ from the top of a tower. If the height of the tower is $25\\text{ m}$, the time taken by the ball to reach the ground is (take $g = 10\\text{ m/s}^2$):", + question_text_hi="एक मीनार के शीर्ष से एक गेंद को $20\\text{ मी/से}$ के वेग से लंबवत ऊपर की ओर फेंका जाता है। यदि मीनार की ऊंचाई $25\\text{ मीटर}$ है, तो गेंद को जमीन पर पहुंचने में लगा समय है ($g = 10\\text{ मी/से}^2$ लें):", + options={"A": "$5\\text{ s}$", "B": "$3\\text{ s}$", "C": "$4\\text{ s}$", "D": "$2\\text{ s}$"}, + options_hi={"A": "$5\\text{ सेकंड}$", "B": "$3\\text{ सेकंड}$", "C": "$4\\text{ सेकंड}$", "D": "$2\\text{ सेकंड}$"}, + correct_answer={"correct": "A"}, + solution_text="Using $s = ut + \\frac{1}{2}at^2$. Taking upward direction positive: $-25 = 20t - \\frac{1}{2}(10)t^2 \\implies 5t^2 - 20t - 25 = 0 \\implies t^2 - 4t - 5 = 0 \\implies (t-5)(t+1) = 0$. Since time cannot be negative, $t = 5\\text{ s}$.", + solution_text_hi="$s = ut + \\frac{1}{2}at^2$ का उपयोग करने पर। ऊपर की दिशा को धनात्मक मानने पर: $-25 = 20t - \\frac{1}{2}(10)t^2 \\implies 5t^2 - 20t - 25 = 0 \\implies t^2 - 4t - 5 = 0 \\implies (t-5)(t+1) = 0$। चूंकि समय ऋणात्मक नहीं हो सकता, $t = 5$ सेकंड है।", + language=models.MediumEnum.BILINGUAL, + year=2023, + exam=models.ExamEnum.NEET, + created_by=admin_id + ) + + # Chemistry 1 (NEET) + nq3 = models.Question( + id=uuid4(), + subject="Chemistry", + topic="Periodic Table", + subtopic="Ionic Radii", + difficulty="Medium", + type=models.QuestionTypeEnum.MCQ_SINGLE, + question_text="Which of the following represents the correct order of increasing ionic radii for isoelectronic species?", + question_text_hi="समइलेक्ट्रॉनिक प्रजातियों के लिए बढ़ती हुई आयनिक त्रिज्या का सही क्रम निम्नलिखित में से कौन सा दर्शाता है?", + options={"A": "$\\text{O}^{2-} < \\text{F}^- < \\text{Na}^+$", "B": "$\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$", "C": "$\\text{F}^- < \\text{Na}^+ < \\text{O}^{2-}$", "D": "$\\text{O}^{2-} < \\text{Na}^+ < \\text{F}^-$"}, + options_hi={"A": "$\\text{O}^{2-} < \\text{F}^- < \\text{Na}^+$", "B": "$\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$", "C": "$\\text{F}^- < \\text{Na}^+ < \\text{O}^{2-}$", "D": "$\\text{O}^{2-} < \\text{Na}^+ < \\text{F}^-$"}, + correct_answer={"correct": "B"}, + solution_text="For isoelectronic species, ionic radii decrease with increase in atomic number (or nuclear charge). Nuclear charge: $\\text{Na}^+ (11) > \\text{F}^- (9) > \\text{O}^{2-} (8)$. Therefore, the correct order is $\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$.", + solution_text_hi="समइलेक्ट्रॉनिक प्रजातियों के लिए, परमाणु संख्या (या नाभिकीय आवेश) में वृद्धि के साथ आयनिक त्रिज्या कम होती जाती है। नाभिकीय आवेश: $\\text{Na}^+ (11) > \\text{F}^- (9) > \\text{O}^{2-} (8)$। इसलिए, सही क्रम $\\text{Na}^+ < \\text{F}^- < \\text{O}^{2-}$ है।", + language=models.MediumEnum.BILINGUAL, + year=2024, + exam=models.ExamEnum.NEET, + created_by=admin_id + ) + + neet_physics_dummies = generate_dummy_questions("Physics", 49, models.ExamEnum.NEET, admin_id, start_idx=2) + neet_chem_dummies = generate_dummy_questions("Chemistry", 49, models.ExamEnum.NEET, admin_id, start_idx=2) + neet_botany_dummies = generate_dummy_questions("Botany", 49, models.ExamEnum.NEET, admin_id, start_idx=2) + neet_zoology_dummies = generate_dummy_questions("Zoology", 50, models.ExamEnum.NEET, admin_id, start_idx=1) + + session.add_all([nq1, nq2, nq3]) + session.add_all(neet_physics_dummies) + session.add_all(neet_chem_dummies) + session.add_all(neet_botany_dummies) + session.add_all(neet_zoology_dummies) + + # --- TESTS CREATION --- + + # 1. JEE Mock Test + jee_test = models.Test( + id=jee_test_id, + title="JEE Main Full Mock Test #1 (2025)", + type="Mock", + exam_pattern=models.ExamEnum.JEE_MAIN, + sections={ + "Physics": { + "name": "Physics", + "questions": [str(jq1.id), str(jq2.id)] + [str(q.id) for q in jee_physics_dummies], + "time_limit": 60 + }, + "Chemistry": { + "name": "Chemistry", + "questions": [str(jq3.id)] + [str(q.id) for q in jee_chem_dummies], + "time_limit": 60 + }, + "Mathematics": { + "name": "Mathematics", + "questions": [str(jq4.id)] + [str(q.id) for q in jee_math_dummies], + "time_limit": 60 + } + }, + total_time=180, + difficulty="Mixed", + scheduled_date=datetime.utcnow(), + created_by=admin_id + ) + session.add(jee_test) + + # 2. NEET Mock Test + neet_test = models.Test( + id=neet_test_id, + title="NEET UG Full Mock Test #1 (2025)", + type="Mock", + exam_pattern=models.ExamEnum.NEET, + sections={ + "Physics": { + "name": "Physics", + "questions": [str(nq2.id)] + [str(q.id) for q in neet_physics_dummies], + "time_limit": 45 + }, + "Chemistry": { + "name": "Chemistry", + "questions": [str(nq3.id)] + [str(q.id) for q in neet_chem_dummies], + "time_limit": 45 + }, + "Botany": { + "name": "Botany", + "questions": [str(nq1.id)] + [str(q.id) for q in neet_botany_dummies], + "time_limit": 45 + }, + "Zoology": { + "name": "Zoology", + "questions": [str(q.id) for q in neet_zoology_dummies], + "time_limit": 45 + } + }, + total_time=180, + difficulty="Mixed", + scheduled_date=datetime.utcnow(), + created_by=admin_id + ) + session.add(neet_test) + + session.commit() + + print("\n=== LOCAL SEED COMPLETED SUCCESSFULLY ===") + print(f"JEE Test ID: {jee_test.id}") + print(f"NEET Test ID: {neet_test.id}\n") + +if __name__ == "__main__": + seed_local_database() diff --git a/backend/worker.py b/backend/worker.py new file mode 100644 index 0000000..f04fb4b --- /dev/null +++ b/backend/worker.py @@ -0,0 +1,84 @@ +import os +from datetime import datetime, timedelta +from celery import Celery +from celery.schedules import crontab +from sqlmodel import Session, select +import json + +from database import engine +import models + +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") + +# Initialize Celery connected to our local Redis broker +celery_app = Celery( + "pragativeer_tasks", + broker=REDIS_URL, + backend=REDIS_URL +) + +# Configure timezone to IST for accurate 5:00 AM scheduling +celery_app.conf.update( + timezone='Asia/Kolkata', + enable_utc=True, +) + +@celery_app.task(name="generate_daily_study_plans", bind=True, default_retry_delay=60, max_retries=3) +def generate_daily_study_plans(self): + """ + Runs daily to evaluate students' weak areas and generate + a personalized AI study plan for the day. + """ + try: + with Session(engine) as session: + # Fetch all active users on the AI Coach tier + ai_students = session.exec( + select(models.User).where(models.User.tier == models.TierEnum.AI_COACH) + ).all() + + today = datetime.utcnow().date() + plans_created = 0 + + for student in ai_students: + # 1. Fetch pending spaced repetition mistakes for today + pending_mistakes = session.exec( + select(models.Mistake) + .where(models.Mistake.user_id == student.id) + .where(models.Mistake.is_mastered == False) + .where(models.Mistake.next_review <= datetime.utcnow()) + ).all() + + # 2. Extract weak topics (for revision recommendation) + topics = [m.topic for m in pending_mistakes] + most_weak_topic = max(set(topics), key=topics.count) if topics else "Mixed Syllabus" + + # 3. Construct the time-budgeted dynamic task list + tasks = [ + {"type": "Revision", "description": f"Revise {most_weak_topic}", "duration_minutes": 15, "status": "Pending"}, + {"type": "Mistake_Review", "description": "Daily Mistake Review Session", "duration_minutes": 10, "status": "Pending"}, + {"type": "DPS", "description": f"Attempt {most_weak_topic} Practice Sheet", "duration_minutes": 20, "status": "Pending"} + ] + + # 4. Save to Database + new_plan = models.StudyPlan( + user_id=student.id, + date=today, + tasks=tasks, + is_completed=False + ) + session.add(new_plan) + plans_created += 1 + + session.commit() + return f"Successfully generated {plans_created} daily study plans." + except Exception as exc: + print(f"Database error during study plan generation: {exc}") + raise self.retry(exc=exc) + +# Schedule the tasks using Celery Beat +celery_app.conf.beat_schedule = { + 'generate-study-plans-every-morning': { + 'task': 'generate_daily_study_plans', + 'schedule': crontab(hour=5, minute=0), # 5:00 AM IST + }, +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6a1b3f2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,116 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: pragativeer_db + environment: + POSTGRES_USER: pragativeer_admin + POSTGRES_PASSWORD: local_secure_password_2026 + POSTGRES_DB: pragativeer_prod + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - pragativeer_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pragativeer_admin -d pragativeer_prod"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: pragativeer_cache + ports: + - "6379:6379" + volumes: + - redisdata:/data + networks: + - pragativeer_network + + + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + container_name: pragativeer_backend + volumes: + - ./backend:/app + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod + - REDIS_URL=redis://redis:6379/0 + - ENV=development + env_file: + - ./backend/.env + depends_on: + postgres: + condition: service_healthy + networks: + - pragativeer_network + + worker: + build: + context: ./backend + dockerfile: Dockerfile.dev + container_name: pragativeer_worker + command: celery -A worker.celery_app worker --loglevel=info + volumes: + - ./backend:/app + environment: + - DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod + - REDIS_URL=redis://redis:6379/0 + env_file: + - ./backend/.env + depends_on: + - postgres + - redis + networks: + - pragativeer_network + + beat: + build: + context: ./backend + dockerfile: Dockerfile.dev + container_name: pragativeer_beat + command: celery -A worker.celery_app beat --loglevel=info + volumes: + - ./backend:/app + environment: + - DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod + - REDIS_URL=redis://redis:6379/0 + env_file: + - ./backend/.env + depends_on: + - postgres + - redis + networks: + - pragativeer_network + + frontend: + build: + context: . + dockerfile: Dockerfile.dev + container_name: pragativeer_frontend + ports: + - "3000:3000" + volumes: + - .:/app + - /app/node_modules + environment: + - NEXT_PUBLIC_API_URL=http://localhost:8000 + depends_on: + - backend + networks: + - pragativeer_network + +volumes: + pgdata: + redisdata: + +networks: + pragativeer_network: + driver: bridge diff --git a/package-lock.json b/package-lock.json index 157837f..3bb36ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,20 @@ "dependencies": { "clsx": "^2.1.1", "framer-motion": "^12.41.0", + "html-to-image": "^1.11.13", + "idb-keyval": "^6.2.6", + "katex": "^0.17.0", "lucide-react": "^1.21.0", "next": "16.2.9", "react": "19.2.4", "react-dom": "19.2.4", "react-katex": "^3.1.0", - "recharts": "^3.9.0", - "tailwind-merge": "^3.6.0" + "react-markdown": "^10.1.0", + "recharts": "^3.9.2", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", + "tailwind-merge": "^3.6.0", + "zustand": "^5.0.14" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1279,16 +1286,6 @@ } } }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.8", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", - "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1662,13 +1659,39 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1683,6 +1706,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -1697,7 +1741,6 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1723,6 +1766,12 @@ "@types/react": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2024,6 +2073,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", @@ -2634,6 +2689,16 @@ "node": ">= 0.4" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2791,6 +2856,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2808,6 +2883,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2843,6 +2958,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -2885,7 +3010,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -3074,7 +3198,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3094,6 +3217,19 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3137,6 +3273,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3147,6 +3292,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3203,6 +3361,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -3830,6 +4000,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3846,6 +4026,12 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4305,6 +4491,174 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -4322,6 +4676,28 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/idb-keyval": { + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.6.tgz", + "integrity": "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA==", + "license": "Apache-2.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4333,9 +4709,9 @@ } }, "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", "license": "MIT", "funding": { "type": "opencollective", @@ -4369,6 +4745,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4393,6 +4775,30 @@ "node": ">=12" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4551,6 +4957,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -4626,6 +5042,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -4679,6 +5105,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4959,9 +5397,9 @@ } }, "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -5302,6 +5740,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5353,6 +5801,178 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5363,6 +5983,483 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5419,7 +6516,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5776,6 +6872,43 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5882,6 +7015,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5953,6 +7096,49 @@ "react": ">=15.3.2 <20" } }, + "node_modules/react-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", @@ -5977,9 +7163,9 @@ } }, "node_modules/recharts": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.0.tgz", - "integrity": "sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz", + "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", "license": "MIT", "workspaces": [ "www" @@ -5990,7 +7176,7 @@ "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", - "immer": "^10.1.1", + "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", @@ -6065,6 +7251,90 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -6436,6 +7706,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6571,6 +7851,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6594,6 +7888,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -6741,6 +8053,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6941,6 +8273,121 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -7029,6 +8476,48 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -7051,6 +8540,16 @@ "d3-timer": "^3.0.1" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7208,6 +8707,45 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 454c97c..2a82037 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,20 @@ "dependencies": { "clsx": "^2.1.1", "framer-motion": "^12.41.0", + "html-to-image": "^1.11.13", + "idb-keyval": "^6.2.6", + "katex": "^0.17.0", "lucide-react": "^1.21.0", "next": "16.2.9", "react": "19.2.4", "react-dom": "19.2.4", "react-katex": "^3.1.0", - "recharts": "^3.9.0", - "tailwind-merge": "^3.6.0" + "react-markdown": "^10.1.0", + "recharts": "^3.9.2", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", + "tailwind-merge": "^3.6.0", + "zustand": "^5.0.14" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/pragativeer-prd (4).pdf b/pragativeer-prd (4).pdf new file mode 100644 index 0000000..756db6b Binary files /dev/null and b/pragativeer-prd (4).pdf differ diff --git a/src/app/admin/students/page.tsx b/src/app/admin/students/page.tsx index 931a21c..6411ff3 100644 --- a/src/app/admin/students/page.tsx +++ b/src/app/admin/students/page.tsx @@ -4,11 +4,11 @@ import { Search, Download, MoreVertical } from "lucide-react" export default function StudentsPage() { const students = [ - { id: "STU-001", name: "Rahul Sharma", email: "rahul@example.com", tier: "AI Pro", target: "NEET", joined: "Oct 12, 2025", tests: 42 }, - { id: "STU-002", name: "Priya Patel", email: "priya@example.com", tier: "Basic", target: "JEE Main", joined: "Nov 05, 2025", tests: 14 }, - { id: "STU-003", name: "Amit Kumar", email: "amit@example.com", tier: "AI Pro", target: "JEE Adv", joined: "Dec 01, 2025", tests: 89 }, - { id: "STU-004", name: "Sneha Reddy", email: "sneha@example.com", tier: "AI Pro", target: "NEET", joined: "Dec 15, 2025", tests: 34 }, - { id: "STU-005", name: "Vikram Singh", email: "vikram@example.com", tier: "Basic", target: "NEET", joined: "Jan 10, 2026", tests: 5 }, + { id: "STU-001", name: "Rahul Sharma", email: "rahul@example.com", tier: "AI Pro", target: "NEET", joined: "Oct 12, 2025", tests: 42, streak: 45, lastLogin: "Today, 08:30 AM" }, + { id: "STU-002", name: "Priya Patel", email: "priya@example.com", tier: "Basic", target: "JEE Main", joined: "Nov 05, 2025", tests: 14, streak: 2, lastLogin: "Yesterday, 14:15 PM" }, + { id: "STU-003", name: "Amit Kumar", email: "amit@example.com", tier: "AI Pro", target: "JEE Adv", joined: "Dec 01, 2025", tests: 89, streak: 0, lastLogin: "3 Days Ago" }, + { id: "STU-004", name: "Sneha Reddy", email: "sneha@example.com", tier: "AI Pro", target: "NEET", joined: "Dec 15, 2025", tests: 34, streak: 12, lastLogin: "Today, 11:45 AM" }, + { id: "STU-005", name: "Vikram Singh", email: "vikram@example.com", tier: "Basic", target: "NEET", joined: "Jan 10, 2026", tests: 5, streak: 0, lastLogin: "1 Week Ago" }, ] return ( @@ -41,8 +41,9 @@ export default function StudentsPage() { Plan Tier Target Exam Tests Taken - Joined Date - + Streak + Last Login + Actions @@ -61,8 +62,16 @@ export default function StudentsPage() { {s.target} {s.tests} - {s.joined} - + + 0 ? 'text-orange-500' : 'text-muted-foreground'}`}>{s.streak} + + {s.lastLogin} + + {s.streak === 0 && ( + + )} diff --git a/src/app/api/og/progress/route.tsx b/src/app/api/og/progress/route.tsx new file mode 100644 index 0000000..7bf83ca --- /dev/null +++ b/src/app/api/og/progress/route.tsx @@ -0,0 +1,67 @@ +import { ImageResponse } from 'next/og'; +import { NextRequest } from 'next/server'; + +export const runtime = 'edge'; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + + // Extract dynamic data from the URL query parameters + const streak = searchParams.get('streak') || '0'; + const accuracy = searchParams.get('accuracy') || '0'; + const name = searchParams.get('name') || 'Student'; + + // Renders a high-end, minimalist graphic using pure Tailwind and React + return new ImageResponse( + ( +
+
+ PragatiVeer +
+
+ {name}'s JEE Preparation Progress +
+ +
+ {/* Streak Metric */} +
+ {streak}🔥 + Day Streak +
+ + {/* Accuracy Metric */} +
+ {accuracy}% + Top Accuracy +
+
+ +
+ Join me on pragativeer.com/invite +
+
+ ), + { + width: 1200, + height: 630, + } + ); + } catch (e: any) { + return new Response(`Failed to generate image`, { status: 500 }); + } +} diff --git a/src/app/cbt/[examId]/page.tsx b/src/app/cbt/[examId]/page.tsx index 401c501..1b24e7e 100644 --- a/src/app/cbt/[examId]/page.tsx +++ b/src/app/cbt/[examId]/page.tsx @@ -1,139 +1,571 @@ +"use client" + +import { useEffect, useState, use, useRef } from "react" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" -import { Clock, Flag, ChevronLeft, ChevronRight, Pause, MoreVertical } from "lucide-react" +import { Clock, Flag, ChevronLeft, ChevronRight, Pause as PauseIcon, MoreVertical, Calculator as CalculatorIcon, FlaskConical, Edit3, XCircle, Play, Shield, AlertTriangle } from "lucide-react" import Image from "next/image" +import { useCBTStore, QuestionStatus } from "@/store/cbtStore" +import { useExamTimer } from "@/hooks/useExamTimer" +import ReactMarkdown from "react-markdown" +import remarkMath from "remark-math" +import rehypeKatex from "rehype-katex" +import "katex/dist/katex.min.css" +import { Calculator } from "@/components/CBT/Calculator" +import { Scratchpad } from "@/components/CBT/Scratchpad" +import { PeriodicTable } from "@/components/CBT/PeriodicTable" +import { ExamResult } from "@/components/CBT/ExamResult" + +// Dummy data for testing the CBT client engine +const DUMMY_QUESTIONS = Array.from({ length: 20 }).map((_, i) => ({ + id: `q_${i+1}`, + number: i + 1, + subject: "Physics", + text_en: `A solid cylinder of mass M and radius R is rolling without slipping down an inclined plane of angle θ. Question ${i+1}. What is the minimum coefficient of static friction μ_s required to prevent slipping?`, + text_hi: `द्रव्यमान M और त्रिज्या R का एक ठोस बेलन कोण θ के एक आनत तल पर बिना फिसले लुढ़क रहा है। प्रश्न ${i+1}...`, + options: ['(1/3) tan θ', '(2/3) tan θ', '(1/2) tan θ', 'tan θ'] +})); + +export default function CBTExamPage({ params }: { params: Promise<{ examId: string }> }) { + const resolvedParams = use(params); + const examId = resolvedParams.examId; + const [mounted, setMounted] = useState(false); + const [showCalc, setShowCalc] = useState(false); + const [showScratchpad, setShowScratchpad] = useState(false); + const [showPeriodicTable, setShowPeriodicTable] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [showSubmitConfirm, setShowSubmitConfirm] = useState(false); + + // Anti-cheat state + const [testStarted, setTestStarted] = useState(false); + const [violationCount, setViolationCount] = useState(0); + const [showWarning, setShowWarning] = useState(false); + const hasShownWarning = useRef(false); + const hasAutoSubmitted = useRef(false); + + const { + initExam, + questions, + answers, + currentQuestionIndex, + selectAnswer, + clearResponse, + markForReview, + goToQuestion, + addTimeSpent, + submitExam, + getAttemptPayload, + submitAttemptToServer, + resetExamState, + status + } = useCBTStore(); + + const { formattedTime, isTimedOut } = useExamTimer(); + const [questionStartTime, setQuestionStartTime] = useState(Date.now()); + + // Initialize Exam on mount + useEffect(() => { + async function fetchExamData() { + try { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; + const testRes = await fetch(`${apiUrl}/api/tests/${examId}?user_id=b0e17378-864d-439e-8806-708dc48a891b`); + if (!testRes.ok) throw new Error("Failed to fetch test structure"); + const testData = await testRes.json(); + + // Extract all question IDs from sections + const questionIds: string[] = []; + Object.values(testData.sections).forEach((section: any) => { + questionIds.push(...section.questions); + }); + + // Fetch full question objects + const fetchedQuestions = await Promise.all( + questionIds.map(async (id, idx) => { + const qRes = await fetch(`${apiUrl}/api/questions/${id}`); + const qData = await qRes.json(); + return { + id: qData.id, + number: idx + 1, + subject: qData.subject, + text_en: qData.question_text, + text_hi: qData.question_text_hi || qData.question_text, + options: Object.values(qData.options || {}), + options_hi: Object.values(qData.options_hi || qData.options || {}), + }; + }) + ); + + initExam(examId, fetchedQuestions, testData.total_time * 60); + setMounted(true); + } catch (err) { + console.error("Failed to load exam from network, falling back to dummy data:", err); + initExam(examId, DUMMY_QUESTIONS, 10800); + setMounted(true); + } + } + fetchExamData(); + }, [examId, initExam]); + + // Track time spent per question + useEffect(() => { + if (testStarted) { + setQuestionStartTime(Date.now()); + } + }, [currentQuestionIndex, testStarted]); + + // Anti-Cheat: Tab switching listener + useEffect(() => { + if (!testStarted || status === 'SUBMITTED' || status === 'TIMED_OUT') return; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + setViolationCount((prev) => prev + 1); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + }, [testStarted, status]); + + // Handle side-effects of violation count changing + useEffect(() => { + if (violationCount === 1 && !hasShownWarning.current) { + hasShownWarning.current = true; + setShowWarning(true); + } else if (violationCount >= 2 && status !== 'SUBMITTED' && status !== 'TIMED_OUT' && !hasAutoSubmitted.current) { + hasAutoSubmitted.current = true; + submitExam('SUBMITTED'); + submitAttemptToServer('b0e17378-864d-439e-8806-708dc48a891b') + .then(() => { + window.alert("Test Auto-Submitted due to multiple tab switching violations."); + }) + .catch(err => { + console.error("Auto-submit fetch error:", err); + window.alert("Test Auto-Submitted due to multiple tab switching violations."); + }); + } + }, [violationCount, status, submitExam, submitAttemptToServer]); + + const startTest = async () => { + try { + if (document.documentElement.requestFullscreen) { + await document.documentElement.requestFullscreen(); + } + } catch (e) { + console.warn("Fullscreen API failed", e); + } + setTestStarted(true); + }; + + const handleNext = () => { + if (!currentQuestion) return; + const timeSpent = Math.floor((Date.now() - questionStartTime) / 1000); + addTimeSpent(currentQuestion.id, timeSpent); + if (currentQuestionIndex < questions.length - 1) { + goToQuestion(currentQuestionIndex + 1); + } else { + goToQuestion(0); + } + } + + const handlePrev = () => { + if (!currentQuestion) return; + const timeSpent = Math.floor((Date.now() - questionStartTime) / 1000); + addTimeSpent(currentQuestion.id, timeSpent); + if (currentQuestionIndex > 0) { + goToQuestion(currentQuestionIndex - 1); + } + } + + const handleGridClick = (index: number) => { + if (!currentQuestion) return; + const timeSpent = Math.floor((Date.now() - questionStartTime) / 1000); + addTimeSpent(currentQuestion.id, timeSpent); + goToQuestion(index); + } + + const handleSubmit = () => { + setShowSubmitConfirm(true); + } + + const confirmSubmit = async () => { + setShowSubmitConfirm(false); + submitExam('SUBMITTED'); + const payload = getAttemptPayload('b0e17378-864d-439e-8806-708dc48a891b'); + console.log("EXAM SUBMITTED. PAYLOAD:", JSON.stringify(payload, null, 2)); + await submitAttemptToServer('b0e17378-864d-439e-8806-708dc48a891b'); + } + + if (!mounted || questions.length === 0) return
Loading Exam Engine...
; + + if (!testStarted && status !== 'SUBMITTED' && status !== 'TIMED_OUT') { + return ( +
+
+

+ Secure Exam Environment +

+
+

Target: {questions.some(q => q.subject.toLowerCase() === 'biology') ? 'NEET' : 'JEE'} Pattern Test

+
    +
  • The test will open in Full Screen Mode.
  • +
  • Do not switch tabs or exit full screen.
  • +
  • First violation will result in a strict warning.
  • +
  • Second violation will automatically submit your exam to prevent cheating.
  • +
  • Ensure your internet connection is stable before starting.
  • +
+
+ +
+
+ ); + } + + if (status === 'SUBMITTED' || status === 'TIMED_OUT') { + const payload = getAttemptPayload('b0e17378-864d-439e-8806-708dc48a891b'); + if (!payload) return
Loading results...
; + + return ( + { + resetExamState(); + window.location.reload(); + }} + /> + ) + } + + const currentQuestion = questions[currentQuestionIndex]; + const currentState = answers[currentQuestion.id]; + + // Derive palette stats + const paletteStats = Object.values(answers).reduce( + (acc, val) => { + acc[val.status]++; + return acc; + }, + { UNVISITED: 0, NOT_ANSWERED: 0, ANSWERED: 0, MARKED: 0, ANSWERED_MARKED: 0 } as Record + ); + + // Group questions by subject for the side panel + const questionsBySubject = questions.reduce((acc, q, index) => { + if (!acc[q.subject]) { + acc[q.subject] = []; + } + acc[q.subject].push({ ...q, originalIndex: index }); + return acc; + }, {} as Record); -export default function CBTExamPage({ params }: { params: { examId: string } }) { return ( - <> -
+
+ {/* Warning Modal */} + {showWarning && ( +
+
+
+ +

Warning!

+

You switched tabs or left the secure window.

+

This is your final warning. Doing this again will automatically submit your exam and your score will be evaluated as is.

+ +
+
+
+ )} + +
-
+
PragatiVeer PragatiVeer
-

AITS Full Syllabus Test 4

-

Physics • Section A

+

AITS Full Syllabus Test

+

{currentQuestion.subject}

-
- - 02:45:12 +
+ + +
- - +
- {/* Main Question Area */} -
-
-
- Question 12 -
- +4 / -1 - + {/* Main Question Area - Configured for Bilingual Split Pane */} +
+
+
+
+
+ Question {currentQuestion.number} + English +
+
+ +4 / -1 + +
+
+ +
+ + {currentQuestion.text_en} + +
+ +
+ {currentQuestion.options.map((option: string, idx: number) => { + const letter = String.fromCharCode(65 + idx); + const isSelected = currentState?.selected_answer === letter; + return ( + + ) + })}
+
+ + {/* Optional Bilingual Hindi Pane */} +
+
+
+
+ प्रश्न {currentQuestion.number} + हिंदी +
+
+
+ + {currentQuestion.text_hi} + +
-
-

- A solid cylinder of mass M and radius R is rolling without slipping down an inclined plane of angle θ. - What is the minimum coefficient of static friction μ_s required to prevent slipping? -

-
- -
- {['(1/3) tan θ', '(2/3) tan θ', '(1/2) tan θ', 'tan θ'].map((option, idx) => ( - - ))} +
+ {currentQuestion.options_hi?.map((option: string, idx: number) => { + const letter = String.fromCharCode(65 + idx); + const isSelected = currentState?.selected_answer === letter; + return ( + + ) + })} +
{/* Side Panel for Question Grid */} -
) } diff --git a/src/app/dashboard/ai-coach/page.tsx b/src/app/dashboard/ai-coach/page.tsx new file mode 100644 index 0000000..2e23754 --- /dev/null +++ b/src/app/dashboard/ai-coach/page.tsx @@ -0,0 +1,170 @@ +"use client" + +import { useEffect, useState } from "react" +import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap" +import { StudyPlanCard } from "@/components/AI/StudyPlanCard" +import { ReadinessGauge } from "@/components/AI/ReadinessGauge" +import { CollegePredictorCard } from "@/components/AI/CollegePredictorCard" +import { TutorChat } from "@/components/AI/TutorChat" +import { ReferralCard } from "@/components/AI/ReferralCard" +import { ProgressShare } from "@/components/AI/ProgressShare" +import { MistakeReviewCard, MistakePayload } from "@/components/AI/MistakeReviewCard" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { BrainCircuit, BookOpen, Calculator, Target, RefreshCw, CheckCircle2 } from "lucide-react" + +// Hardcode mock UUID for demo fetching +const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b" + +export default function AICoachDashboard() { + const [mistakes, setMistakes] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [loading, setLoading] = useState(true) + + const fetchMistakes = async () => { + setLoading(true) + try { + const res = await fetch(`http://localhost:8000/api/mistakes/today?user_id=${USER_ID}`) + if (!res.ok) throw new Error("Failed to fetch") + const data = await res.json() + setMistakes(data) + } catch (error) { + console.error(error) + // If backend fails, fallback to empty array + setMistakes([]) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchMistakes() + }, []) + + const submitReview = async (mistakeId: string, selectedAnswer: string) => { + const res = await fetch("http://localhost:8000/api/mistakes/review", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mistake_id: mistakeId, selected_answer: selectedAnswer }) + }) + + if (!res.ok) throw new Error("Failed to submit review") + return await res.json() + } + + const handleNext = () => { + if (currentIndex < mistakes.length - 1) { + setCurrentIndex(prev => prev + 1) + } else { + // Session finished + setCurrentIndex(mistakes.length) // Push out of bounds to trigger complete state + } + } + + return ( +
+
+
+

+ AI Coach +

+

Your personalized SM-2 spaced repetition and analytics engine.

+
+
+ + {/* Summary Metrics */} +
+ + +
+
+

Physics Errors

+

{mistakes.filter(m => m.question.subject === 'Physics').length}

+
+
+
+ + +
+
+

Chemistry Errors

+

{mistakes.filter(m => m.question.subject === 'Chemistry').length}

+
+
+
+ + +
+
+

Maths Errors

+

{mistakes.filter(m => m.question.subject === 'Maths').length}

+
+
+
+
+ + {/* Predictive Intelligence */} +
+ + +
+ + {/* Mistake Review Workspace */} +
+
+

Daily Mistake Review Queue

+ + {currentIndex < mistakes.length ? `${currentIndex + 1} / ${mistakes.length}` : "Finished"} + +
+ + {loading ? ( + + Loading queue... + + ) : mistakes.length === 0 ? ( + + +

You're all caught up!

+

No mistakes scheduled for review today. Go take a test!

+
+ ) : currentIndex < mistakes.length ? ( + + ) : ( + + +

Session Complete

+

Great job clearing your error queue! The SM-2 engine has scheduled your next reviews.

+ +
+ )} +
+ + {/* Heatmap & Timeline */} +
+
+ +
+
+ +
+
+ + {/* Tutor Chat Workspace */} +
+ +
+ + {/* Growth & Sharing Hub */} +
+ + +
+ +
+ ) +} diff --git a/src/app/dashboard/analytics/page.tsx b/src/app/dashboard/analytics/page.tsx index 9968892..3feec3a 100644 --- a/src/app/dashboard/analytics/page.tsx +++ b/src/app/dashboard/analytics/page.tsx @@ -1,7 +1,35 @@ +"use client" + import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" -import { LineChart, Activity, BrainCircuit } from "lucide-react" +import { LineChart as LineChartIcon, Activity, BrainCircuit } from "lucide-react" +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from 'recharts' export default function AnalyticsPage() { + const trendData = [ + { name: 'Week 1', score: 120 }, + { name: 'Week 2', score: 135 }, + { name: 'Week 3', score: 130 }, + { name: 'Week 4', score: 145 }, + { name: 'Week 5', score: 160 }, + { name: 'Week 6', score: 185 }, + ] + + // For heatmap-like visualization (using a barchart with different colors) + const heatmapData = [ + { topic: 'Kinematics', accuracy: 95 }, + { topic: 'Optics', accuracy: 72 }, + { topic: 'Thermodynamics', accuracy: 45 }, + { topic: 'Calculus', accuracy: 38 }, + { topic: 'Organic Chem', accuracy: 88 }, + { topic: 'Inorganic', accuracy: 65 }, + ] + + const getColor = (accuracy: number) => { + if (accuracy >= 80) return '#10b981' // emerald-500 + if (accuracy >= 60) return '#f59e0b' // amber-500 + return '#ef4444' // red-500 + } + return (
@@ -12,16 +40,22 @@ export default function AnalyticsPage() {
- Projected Score Trajectory + Projected Score Trajectory Your mock scores over the last 6 months projected towards the final exam. - - {/* Placeholder for an actual chart library like Recharts */} -
- -

Data gathering in progress...

-

Complete 2 more CBTs to unlock predictive graphing.

-
+ + + + + + + + + +
@@ -39,21 +73,25 @@ export default function AnalyticsPage() { - Subject Mastery + Topic Heatmap (Accuracy) - -
-
Biology 89%
-
-
-
-
Chemistry 68%
-
-
-
-
Physics 42%
-
-
+ + + + + + [`${value}% Accuracy`, 'Accuracy']} + /> + + {heatmapData.map((entry, index) => ( + + ))} + + +
diff --git a/src/app/dashboard/cbt/history/[attemptId]/page.tsx b/src/app/dashboard/cbt/history/[attemptId]/page.tsx new file mode 100644 index 0000000..261e4a4 --- /dev/null +++ b/src/app/dashboard/cbt/history/[attemptId]/page.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useEffect, useState, use } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { ArrowLeft, CheckCircle2, XCircle, Clock, Award } from "lucide-react"; + +export default function AttemptHistoryPage({ params }: { params: Promise<{ attemptId: string }> }) { + const { attemptId } = use(params); + const [loading, setLoading] = useState(true); + const [attempt, setAttempt] = useState(null); + const [test, setTest] = useState(null); + const [questions, setQuestions] = useState>({}); + + const userId = "b0e17378-864d-439e-8806-708dc48a891b"; + + useEffect(() => { + async function fetchHistoryData() { + try { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; + + // 1. Fetch all attempts to find this specific one + const attemptsRes = await fetch(`${apiUrl}/api/users/${userId}/attempts`); + const attemptsData = await attemptsRes.json(); + const foundAttempt = attemptsData.find((a: any) => a.id === attemptId); + + if (!foundAttempt) { + throw new Error("Attempt not found"); + } + setAttempt(foundAttempt); + + // 2. Fetch the test details + const testRes = await fetch(`${apiUrl}/api/tests/${foundAttempt.source_id}?user_id=${userId}`); + const testData = await testRes.json(); + setTest(testData); + + // 3. Fetch all questions for this attempt to build the answer key + const qs: Record = {}; + await Promise.all( + foundAttempt.answers.map(async (ans: any) => { + const qRes = await fetch(`${apiUrl}/api/questions/${ans.question_id}`); + if (qRes.ok) { + const qData = await qRes.json(); + qs[ans.question_id] = qData; + } + }) + ); + setQuestions(qs); + + } catch (err) { + console.error("Failed to load attempt history:", err); + } finally { + setLoading(false); + } + } + fetchHistoryData(); + }, [attemptId, userId]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!attempt) { + return ( +
+ Attempt not found. Go back +
+ ); + } + + const scoreCalc = (attempt.score * 4) - ((attempt.total_questions - attempt.score) * 1); + const maxScore = attempt.total_questions * 4; + + return ( +
+ + Back to Tests + + +
+
+

{test?.title || 'Practice Test'} Solutions

+

Review your performance, detailed solutions, and identify your weak spots.

+
+ +
+ + + +
Score
+
{scoreCalc} / {maxScore}
+
+
+ + + +
Time Taken
+
{Math.floor(attempt.time_taken / 60)}m {attempt.time_taken % 60}s
+
+
+
+
+ +
+

Answer Key & Explanations

+ + {attempt.answers.map((ans: any, idx: number) => { + const q = questions[ans.question_id]; + if (!q) return null; + + return ( + + +
+
+
+ {idx + 1} +
+
+

{q.text}

+
+ {q.topic} + {q.difficulty} +
+
+
+ {ans.is_correct ? ( + Correct + ) : ( + Incorrect + )} +
+ +
+
+

Your Answer

+
+ {q.options[ans.selected_answer] ? `${ans.selected_answer}) ${q.options[ans.selected_answer]}` : 'Not Attempted'} +
+
+
+

Correct Answer

+
+ {q.correct_answer?.correct}) {q.options[q.correct_answer?.correct]} +
+
+
+ + {q.correct_answer?.explanation && ( +
+

Explanation:

+

{q.correct_answer.explanation}

+
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/app/dashboard/cbt/page.tsx b/src/app/dashboard/cbt/page.tsx index a5d603d..db309ec 100644 --- a/src/app/dashboard/cbt/page.tsx +++ b/src/app/dashboard/cbt/page.tsx @@ -1,30 +1,95 @@ -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Activity, Clock, PlayCircle, Search } from "lucide-react" +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Activity, Clock, PlayCircle, Search } from "lucide-react"; + +interface TestData { + id: string; + title: string; + type: string; + exam_pattern: string; + total_time: number; + difficulty: string; +} export default function CBTPage() { - const upcomingExams = [ - { title: "AITS Full Syllabus Test 4", date: "Tomorrow, 10:00 AM", duration: "180 Min", marks: 720 }, - { title: "Part Test 3: Mechanics", date: "Oct 24, 2026", duration: "180 Min", marks: 720 }, - { title: "Weekly Revision Test", date: "Oct 28, 2026", duration: "90 Min", marks: 360 }, - ] + const [exams, setExams] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [targetExam, setTargetExam] = useState(""); + const [attempts, setAttempts] = useState([]); - const pastExams = [ - { title: "AITS Full Syllabus Test 3", date: "Oct 15, 2026", score: 645, total: 720, percentile: "99.4" }, - { title: "Part Test 2: Electromagnetism", date: "Oct 08, 2026", score: 680, total: 720, percentile: "99.8" }, - ] + const userId = "b0e17378-864d-439e-8806-708dc48a891b"; + + useEffect(() => { + async function loadCBTExams() { + try { + // 1. Fetch user target exam + const userRes = await fetch(`http://localhost:8000/api/users/${userId}`); + if (!userRes.ok) throw new Error("Failed to load user target exam"); + const userData = await userRes.json(); + const pattern = userData.target_exam; + setTargetExam(pattern === "NEET" ? "NEET" : "JEE Main"); + + // 2. Fetch tests filtered by exam + const testRes = await fetch(`http://localhost:8000/api/tests?exam_pattern=${pattern}`); + if (!testRes.ok) throw new Error("Failed to fetch tests"); + const testsData = await testRes.json(); + setExams(testsData); + + // 3. Fetch user attempts + const attemptsRes = await fetch(`http://localhost:8000/api/users/${userId}/attempts`); + if (attemptsRes.ok) { + const attemptsData = await attemptsRes.json(); + setAttempts(attemptsData); + } + } catch (err) { + console.error("Error loading exams:", err); + } finally { + setLoading(false); + } + } + loadCBTExams(); + }, []); + + const filteredExams = exams.filter(exam => + exam.title.toLowerCase().includes(search.toLowerCase()) + ); + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' }); + } + + // Get the highest score for each test if attempted multiple times + const attemptsByTest = attempts.reduce((acc, curr) => { + if (!acc[curr.source_id] || acc[curr.source_id].score < curr.score) { + acc[curr.source_id] = curr; + } + return acc; + }, {} as Record); return (

Computer Based Tests (CBT)

-

Real exam environment simulations to conquer NEET/JEE.

+

+ Real exam environment simulations to conquer {targetExam || "NEET/JEE"}. +

- + setSearch(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[#00F0FF] outline-none" + />
@@ -32,23 +97,49 @@ export default function CBTPage() {

Upcoming & Active Exams

-
- {upcomingExams.map((exam, i) => ( - - - Mock Test -

{exam.title}

-
-
{exam.date}
-
{exam.duration} • {exam.marks} Marks
-
- -
-
- ))} -
+ + {loading ? ( +
Loading test catalog...
+ ) : filteredExams.length === 0 ? ( +
+ No active exams found for {targetExam || "your target exam"}. Try switching it in settings! +
+ ) : ( +
+ {filteredExams.map((exam) => { + const attempt = attemptsByTest[exam.id]; + const isAttempted = !!attempt; + + return ( + + +
+ + {exam.type} Test + + {isAttempted && ( + + Attempted + + )} +
+

+ {exam.title} +

+
+
Available Now
+
{exam.total_time} Min • {exam.difficulty} Difficulty
+
+ + + +
+
+ )})} +
+ )}
@@ -56,29 +147,44 @@ export default function CBTPage() { Past Attempts
- {pastExams.map((exam, i) => ( + {attempts.length === 0 ? ( +
+ You haven't attempted any CBT exams yet. +
+ ) : attempts.map((attempt, i) => { + const exam = exams.find(e => e.id === attempt.source_id); + const scoreCalc = (attempt.score * 4) - ((attempt.total_questions - attempt.score) * 1); // rough calc for display + + return ( - -
-

{exam.title}

-

{exam.date}

+ +
+

{exam?.title || 'Unknown Test'}

+

{formatDate(attempt.submitted_at)}

-
+
-
Score
-
{exam.score}/{exam.total}
+
Score
+
+ {scoreCalc} + /{attempt.total_questions * 4} +
-
Percentile
-
{exam.percentile}
+
Accuracy
+
{Math.round(attempt.accuracy)}%
- + + +
- ))} + )})}
- ) + ); } diff --git a/src/app/dashboard/community/page.tsx b/src/app/dashboard/community/page.tsx new file mode 100644 index 0000000..dd694a7 --- /dev/null +++ b/src/app/dashboard/community/page.tsx @@ -0,0 +1,195 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" +import { MessageSquare, ThumbsUp, ChevronUp, Search, Image as ImageIcon, CheckCircle } from "lucide-react" + +interface Doubt { + id: string + author: string + avatar: string + timeAgo: string + subject: string + topic: string + title: string + content: string + upvotes: number + replies: number + isSolved: boolean + hasModeratorReply: boolean +} + +export default function CommunityPage() { + const [activeFilter, setActiveFilter] = useState<'all' | 'my-doubts' | 'solved' | 'unsolved'>('all') + const [searchQuery, setSearchQuery] = useState('') + + const mockDoubts: Doubt[] = [ + { + id: "1", + author: "Rahul Verma", + avatar: "RV", + timeAgo: "2 hours ago", + subject: "Physics", + topic: "Rotational Mechanics", + title: "Why does friction act forwards in a rolling wheel?", + content: "I'm confused about the direction of friction when a car accelerates forward. My book says friction pushes the wheel forward, but shouldn't it oppose motion?", + upvotes: 42, + replies: 5, + isSolved: true, + hasModeratorReply: true + }, + { + id: "2", + author: "Priya Sharma", + avatar: "PS", + timeAgo: "5 hours ago", + subject: "Chemistry", + topic: "Chemical Kinetics", + title: "How to quickly identify a pseudo first-order reaction?", + content: "Are there any tricks to spot these in MCQs? I always end up wasting 2 minutes trying to derive the rate law.", + upvotes: 18, + replies: 2, + isSolved: false, + hasModeratorReply: false + }, + { + id: "3", + author: "Demo Student", + avatar: "DS", + timeAgo: "1 day ago", + subject: "Mathematics", + topic: "Definite Integration", + title: "Applying King's Rule when limits are -a to a", + content: "I keep messing up the signs when applying the property f(x) + f(-x). Can someone show a step-by-step for the modulus function?", + upvotes: 12, + replies: 8, + isSolved: true, + hasModeratorReply: true + } + ] + + const filteredDoubts = mockDoubts.filter(d => { + if (activeFilter === 'my-doubts' && d.author !== "Demo Student") return false + if (activeFilter === 'solved' && !d.isSolved) return false + if (activeFilter === 'unsolved' && d.isSolved) return false + if (searchQuery && !d.title.toLowerCase().includes(searchQuery.toLowerCase())) return false + return true + }) + + return ( +
+
+
+

+ + Doubt Forum +

+

Ask questions, share knowledge, and learn together with the community.

+
+ + +
+ +
+
+
+ +
+ setSearchQuery(e.target.value)} + /> +
+ +
+ setActiveFilter('all')}>All Doubts + setActiveFilter('my-doubts')}>My Doubts + setActiveFilter('solved')}>Solved + setActiveFilter('unsolved')}>Unsolved +
+
+ +
+ {filteredDoubts.map((doubt) => ( + + + {/* Upvote Sidebar */} +
+ + {doubt.upvotes} +
+ + {/* Main Content */} +
+
+
+ {doubt.subject} + • {doubt.topic} +
+
{doubt.timeAgo}
+
+ +

{doubt.title}

+

{doubt.content}

+ +
+
+
+ {doubt.avatar} +
+ {doubt.author} + {doubt.hasModeratorReply && ( + + Expert Replied + + )} +
+ +
+ {doubt.isSolved && ( + + Solved + + )} +
+ + {doubt.replies} Replies +
+
+
+
+
+
+ ))} + {filteredDoubts.length === 0 && ( +
+ +

No doubts found matching your criteria.

+
+ )} +
+
+ ) +} + +function FilterButton({ children, active, onClick }: { children: React.ReactNode, active: boolean, onClick: () => void }) { + return ( + + ) +} diff --git a/src/app/dashboard/dpp/page.tsx b/src/app/dashboard/dpp/page.tsx index 0b38312..e90726e 100644 --- a/src/app/dashboard/dpp/page.tsx +++ b/src/app/dashboard/dpp/page.tsx @@ -1,29 +1,55 @@ import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" -import { BrainCircuit, Play, CheckCircle2, ChevronRight } from "lucide-react" +import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock } from "lucide-react" export default function DPPPage() { const dpps = [ - { title: "Rotational Dynamics: Top Overturning", subject: "Physics", count: 15, status: "Pending", priority: "High" }, - { title: "Chemical Kinetics: First Order Reactions", subject: "Chemistry", count: 20, status: "Pending", priority: "Medium" }, - { title: "Genetics: Mendelian Inheritance", subject: "Biology", count: 25, status: "Completed", score: "23/25" }, - { title: "Calculus: Definite Integration", subject: "Mathematics", count: 15, status: "Completed", score: "14/15" }, + { title: "Top Overturning", subject: "Physics", topic: "Rotational Dynamics", difficulty: "Hard", count: 15, status: "Pending", priority: "High" }, + { title: "First Order Reactions", subject: "Chemistry", topic: "Chemical Kinetics", difficulty: "Medium", count: 20, status: "Pending", priority: "Medium" }, + { title: "Mendelian Inheritance", subject: "Biology", topic: "Genetics", difficulty: "Easy", count: 25, status: "Completed", score: "23/25" }, + { title: "Definite Integration", subject: "Mathematics", topic: "Calculus", difficulty: "Mixed", count: 15, status: "Completed", score: "14/15" }, ] + const getDifficultyColor = (diff: string) => { + switch(diff) { + case 'Easy': return 'text-emerald-500 bg-emerald-500/10 border-emerald-500/20'; + case 'Medium': return 'text-amber-500 bg-amber-500/10 border-amber-500/20'; + case 'Hard': return 'text-destructive bg-destructive/10 border-destructive/20'; + default: return 'text-primary bg-primary/10 border-primary/20'; + } + } + return (
-
+

Daily Practice Problems (DPP)

Hyper-personalized problem sets generated daily by our AI Evaluator to target your specific weak areas based on past exam performance.

-
+
+
+ Difficulty: + {['Mixed', 'Easy', 'Medium', 'Hard'].map((diff) => ( + + ))} +
+ Pro +
+
+ +
+
{dpps.map((dpp, i) => ( @@ -33,14 +59,23 @@ export default function DPPPage() { {dpp.status === 'Completed' ? :
}
-
{dpp.subject}
-

{dpp.title}

+
+ {dpp.subject} + • + {dpp.topic} +
+

+ {dpp.title} + + {dpp.difficulty} + +

{dpp.count} Questions • {dpp.status === 'Completed' ? `Score: ${dpp.score}` : `${dpp.priority} Priority`}
-
+
{dpp.status === 'Completed' ? ( ) : ( diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 28cb838..30584e3 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -5,6 +5,11 @@ import { BookOpen, LayoutDashboard, Settings, FileText, Activity, LineChart, Tro import Link from "next/link" import Image from "next/image" import { usePathname } from "next/navigation" +import { LanguageToggle } from "@/components/LanguageToggle" +import { ThemeToggle } from "@/components/ThemeToggle" +import { TierBadge } from "@/components/TierBadge" +import { StreakCounter } from "@/components/StreakCounter" +import { AITutorChat } from "@/components/AI/AITutorChat" export default function DashboardLayout({ children }: { children: ReactNode }) { const pathname = usePathname() @@ -50,6 +55,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { } label="Daily Practice" active={pathname.startsWith("/dashboard/dpp")} /> } label="Study Materials" active={pathname.startsWith("/dashboard/materials")} /> +
+

Community & Tracking

+
+ } label="Leaderboard" active={pathname.startsWith("/dashboard/leaderboard")} /> + } label="Syllabus Tracker" active={pathname.startsWith("/dashboard/progress")} /> + } label="Doubt Forum" active={pathname.startsWith("/dashboard/community")} /> +

AI Intelligence

@@ -57,8 +69,24 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { } label="Rank Predictor" active={pathname.startsWith("/dashboard/rank-predictor")} /> -
- } label="Settings" active={pathname.startsWith("/dashboard/settings")} /> +
+
+ + + +
+ + + + Settings + {/* Functional User Dropdown */}
@@ -81,15 +109,15 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { + + +
+
+ +
+
+
+ RANK + STUDENT +
+ SCORE + STREAK +
+
+ +
+ {rankings.map((student) => ( +
+
+ {student.rank === 1 ? : + student.rank === 2 ? : + student.rank === 3 ? : + #{student.rank}} +
+ +
+
+ {student.name.substring(0, 2).toUpperCase()} +
+
+

+ {student.isCurrentUser ? (isAnonymous ? "Anonymous User" : student.name + " (You)") : student.name} +

+

+ Accuracy: {student.accuracy} +

+
+
+ +
+
+ {student.score.toLocaleString()} +
+
+ {student.streak} +
+
+
+ ))} +
+
+ +
+ + + Your Standing + + +
#6
+

Top 15% of users

+

+ You are 150 points away from breaking into the Top 5. Complete today's DPS to earn your streak bonus! +

+ +
+ Anonymous Mode + +
+
+
+
+
+
+ ) +} diff --git a/src/app/dashboard/materials/page.tsx b/src/app/dashboard/materials/page.tsx index fe029f1..7b82863 100644 --- a/src/app/dashboard/materials/page.tsx +++ b/src/app/dashboard/materials/page.tsx @@ -6,7 +6,7 @@ export default function MaterialsPage() { { name: "Physics Formula Sheets", items: 12, color: "text-blue-500" }, { name: "Organic Chem Mind Maps", items: 8, color: "text-emerald-500" }, { name: "Biology NCERT Highlights", items: 22, color: "text-rose-500" }, - { name: "Past Year Papers (2015-2025)", items: 10, color: "text-amber-500" }, + { name: "Math Formulae", items: 15, color: "text-amber-500" }, ] const recentFiles = [ diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 7c668bd..d52fbad 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,76 +1,117 @@ +"use client" + +import { useRef } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Activity, BrainCircuit, ArrowRight, Clock } from "lucide-react" +import { Activity, BrainCircuit, ArrowRight, Clock, Target, CheckCircle2, TrendingUp, Lock, Trophy, Share2 } from "lucide-react" +import * as htmlToImage from 'html-to-image' +import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap" +import { AdaptiveStudyPlan } from "@/components/AI/AdaptiveStudyPlan" export default function DashboardPage() { + const progressRef = useRef(null) + + const handleExport = async () => { + if (progressRef.current) { + try { + const dataUrl = await htmlToImage.toPng(progressRef.current, { backgroundColor: '#09090b' }) + const link = document.createElement('a') + link.download = 'pragativeer-progress.png' + link.href = dataUrl + link.click() + } catch (err) { + console.error("Failed to export image", err) + } + } + } + return (
-
+
-

Welcome back, Student

-

Here is your daily AI-curated study plan.

+

Welcome back, Demo Student

+

Let's keep up the momentum for JEE Advanced 2027.

-
- {/* AI Predicted Weak Areas */} + {/* Free Tier Upgrade CTA Banner */} + +
+ +
+ +
+ Upgrade to Pro +

Unlock Unlimited DPS & Mocks

+

+ You're currently on the Free Tier. Upgrade to Pro for ₹299/mo to access unlimited Daily Practice Sheets, weekly CBT tests, and advanced analytics. +

+
+ +
+
+ + {/* Basic Performance Dashboard (F-05) */}
-

AI Focus Areas

+

Performance Overview

+
-
- - - Rotational Motion - Physics - - -
- Mastery - 42% -
-
-
-
+
+ + +
+

Today's Score

+

42 / 60

+

+5 from yesterday

- - - Chemical Kinetics - Chemistry - - -
- Mastery - 68% -
-
-
-
+ + +
+

Overall Accuracy

+

68%

+

Needs Improvement

- - - Genetics - Biology - - -
- Mastery - 89% -
-
-
-
+ + +
+

Qs Attempted

+

1,245

+

Lifetime total

+
+
+ +
+ +
+

City Rank

+

#42 / 1,200

+

Top 5% in Jaipur

+
+
+ +
+
+ +
+
+
{/* Dynamic DPP Modules */}
@@ -96,19 +137,19 @@ export default function DashboardPage() { - +
-
- +
+
-

Biology: Human Physiology

-

30 Questions • Revision

+

Biology: Human Physiology

+

Pro Feature

- @@ -136,10 +177,10 @@ export default function DashboardPage() {
-

Part Test 3: Mechanics

-

Oct 24, 2026

+

Part Test 3: Mechanics

+

Oct 24, 2026

- + Pro Only
diff --git a/src/app/dashboard/progress/page.tsx b/src/app/dashboard/progress/page.tsx new file mode 100644 index 0000000..93d498e --- /dev/null +++ b/src/app/dashboard/progress/page.tsx @@ -0,0 +1,192 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { ChevronDown, ChevronRight, CheckCircle2, Circle, Clock, Activity, Target } from "lucide-react" + +type ChapterStatus = 'Mastered' | 'Practiced' | 'In Progress' | 'Not Started' + +interface Topic { + name: string + status: ChapterStatus + accuracy: number + questionsAttempted: number +} + +interface Chapter { + name: string + topics: Topic[] + isExpanded: boolean +} + +interface Subject { + name: string + chapters: Chapter[] + completionPct: number +} + +const getStatusIcon = (status: ChapterStatus) => { + switch (status) { + case 'Mastered': return + case 'Practiced': return + case 'In Progress': return + case 'Not Started': return + } +} + +export default function ProgressTrackerPage() { + const [subjects, setSubjects] = useState([ + { + name: "Physics", + completionPct: 42, + chapters: [ + { + name: "Kinematics", + isExpanded: true, + topics: [ + { name: "Motion in One Dimension", status: "Mastered", accuracy: 92, questionsAttempted: 150 }, + { name: "Projectile Motion", status: "Practiced", accuracy: 75, questionsAttempted: 85 }, + { name: "Relative Velocity", status: "In Progress", accuracy: 45, questionsAttempted: 20 }, + ] + }, + { + name: "Laws of Motion", + isExpanded: false, + topics: [ + { name: "Newton's First Law", status: "Practiced", accuracy: 88, questionsAttempted: 40 }, + { name: "Friction", status: "In Progress", accuracy: 50, questionsAttempted: 35 }, + ] + }, + { + name: "Thermodynamics", + isExpanded: false, + topics: [ + { name: "First Law", status: "Not Started", accuracy: 0, questionsAttempted: 0 }, + { name: "Carnot Engine", status: "Not Started", accuracy: 0, questionsAttempted: 0 }, + ] + } + ] + }, + { + name: "Chemistry", + completionPct: 68, + chapters: [ + { + name: "Atomic Structure", + isExpanded: false, + topics: [ + { name: "Bohr Model", status: "Mastered", accuracy: 95, questionsAttempted: 110 }, + { name: "Quantum Numbers", status: "Mastered", accuracy: 88, questionsAttempted: 95 }, + ] + } + ] + }, + { + name: "Mathematics", + completionPct: 22, + chapters: [ + { + name: "Calculus", + isExpanded: false, + topics: [ + { name: "Limits", status: "In Progress", accuracy: 60, questionsAttempted: 45 }, + { name: "Derivatives", status: "Not Started", accuracy: 0, questionsAttempted: 0 }, + ] + } + ] + } + ]) + + const toggleChapter = (subjectIndex: number, chapterIndex: number) => { + const newSubjects = [...subjects] + newSubjects[subjectIndex].chapters[chapterIndex].isExpanded = !newSubjects[subjectIndex].chapters[chapterIndex].isExpanded + setSubjects(newSubjects) + } + + return ( +
+
+

+ + Syllabus Tracker +

+

Track your syllabus coverage and mastery per chapter to ensure no gaps.

+
+ +
+ {subjects.map((sub, i) => ( + + +

{sub.name}

+
{sub.completionPct}%
+
+
+
+
+
+ ))} +
+ +
+ {subjects.map((subject, subIndex) => ( + + + {subject.name} Syllabus + + +
+ {subject.chapters.map((chapter, chapIndex) => ( +
+ + + {chapter.isExpanded && ( +
+
+ TOPIC + ACCURACY + QUESTIONS +
+ {chapter.topics.map((topic, topIndex) => ( +
+
+ {getStatusIcon(topic.status)} +
+

{topic.name}

+

{topic.status}

+
+
+
+ {topic.accuracy > 0 ? ( + 80 ? 'text-emerald-500' : topic.accuracy > 60 ? 'text-amber-500' : 'text-red-500'}`}> + {topic.accuracy}% + + ) : -} +
+
+ {topic.questionsAttempted > 0 ? topic.questionsAttempted : '-'} +
+
+ ))} +
+ )} +
+ ))} +
+
+
+ ))} +
+
+ ) +} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index f3dc1f0..600df78 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -1,7 +1,69 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" +"use client"; + +import { useEffect, useState } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; export default function SettingsPage() { + const [targetExam, setTargetExam] = useState<"JEE_Main" | "NEET">("JEE_Main"); + const [targetYear, setTargetYear] = useState("2026"); + const [firstName, setFirstName] = useState("Rahul"); + const [lastName, setLastName] = useState("Sharma"); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const userId = "b0e17378-864d-439e-8806-708dc48a891b"; + + useEffect(() => { + async function fetchUserData() { + try { + const res = await fetch(`http://localhost:8000/api/users/${userId}`); + if (res.ok) { + const data = await res.json(); + setTargetExam(data.target_exam === "NEET" ? "NEET" : "JEE_Main"); + if (data.name) { + const parts = data.name.split(" "); + setFirstName(parts[0] || ""); + setLastName(parts.slice(1).join(" ") || ""); + } + } + } catch (err) { + console.error("Failed to load user profile:", err); + } finally { + setLoading(false); + } + } + fetchUserData(); + }, []); + + const handleSave = async () => { + setSaving(true); + try { + const res = await fetch(`http://localhost:8000/api/users/${userId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: `${firstName} ${lastName}`.trim(), + target_exam: targetExam, + }), + }); + if (res.ok) { + alert("Settings saved successfully!"); + } else { + alert("Failed to save settings: " + (await res.text())); + } + } catch (err) { + console.error("Error saving settings:", err); + alert("Network error. Could not save settings."); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
Loading settings...
; + } + return (
@@ -19,18 +81,30 @@ export default function SettingsPage() {
- + setFirstName(e.target.value)} + className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" + />
- + setLastName(e.target.value)} + className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" + />
- +
- + @@ -43,26 +117,61 @@ export default function SettingsPage() {
-
- setTargetYear(e.target.value)} + className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF] appearance-none font-medium" + >
+
- ) + ); } diff --git a/src/app/dashboard/streaks/page.tsx b/src/app/dashboard/streaks/page.tsx new file mode 100644 index 0000000..1af821b --- /dev/null +++ b/src/app/dashboard/streaks/page.tsx @@ -0,0 +1,99 @@ +"use client" + +import { Card, CardContent } from "@/components/ui/card" +import { Flame, Gift, Star, Trophy, Target, ArrowRight, CheckCircle2 } from "lucide-react" + +export default function StreaksPage() { + const currentStreak = 12; + const bestStreak = 45; + + const rewards = [ + { streak: 7, title: "Consistent Learner", reward: "1 Free Premium DPP", unlocked: true }, + { streak: 14, title: "Dedicated Scholar", reward: "AI Performance Analysis Report", unlocked: false }, + { streak: 30, title: "Exam Warrior", reward: "1 Full Length Mock Test", unlocked: false }, + { streak: 50, title: "PragatiVeer Champion", reward: "1-on-1 AI Tutor Session", unlocked: false }, + { streak: 100, title: "Unstoppable", reward: "Merch + 50% Off Next Renewal", unlocked: false }, + ]; + + return ( +
+
+
+ +
+
+

Your Learning Streak

+

Build daily habits to unlock premium rewards and boost your rank.

+
+
+ +
+ + + +

{currentStreak} Days

+

CURRENT STREAK

+

Complete a Daily Practice Problem (DPP) or CBT Exam every day to keep your streak alive.

+
+
+ + + +
+
+

STREAK STATS

+ +
+
+
+

Personal Best

+

{bestStreak} Days

+
+
+

Total Streak Points Earned

+

1,450

+
+
+
+ +
+

+ Pro Tip: Your streak directly multiplies your leaderboard score. A longer streak gives you a higher Consistency Bonus. +

+
+
+
+
+ +
+

Streak Rewards

+
+ {rewards.map((reward, i) => ( +
+
+
+ {reward.streak} +
+
+

{reward.title}

+

{reward.reward}

+
+
+
+ {reward.unlocked ? ( +
+ Unlocked +
+ ) : ( +
+ {reward.streak - currentStreak} days to go +
+ )} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/app/globals.css b/src/app/globals.css index 80a63b8..81c049d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -111,3 +111,19 @@ body { 30% { transform: translateX(200%) skewX(-15deg); } 100% { transform: translateX(200%) skewX(-15deg); } } + +/* Custom Scrollbars */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: var(--background); +} +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5adc7bb..51f5ef6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -27,6 +27,7 @@ export default function RootLayout({ {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index 127216c..28a88e3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -131,7 +131,7 @@ export default function MarketingPage() { - @@ -413,16 +413,17 @@ export default function MarketingPage() { transition={{ duration: 0.6, type: "spring" }} > -

Basic

-

Perfect for self-paced revision.

-
₹999/mo
+

Pro

+

Perfect for self-paced practice.

+
₹299/mo
    -
  • 10 Full Syllabus CBTs
  • -
  • Standard Question Bank
  • -
  • Basic Analytics
  • +
  • Unlimited Daily Practice Sheets
  • +
  • Weekly Test Series & Mock Tests
  • +
  • Detailed Performance Analytics
  • +
  • Parent Dashboard Access
- +
@@ -440,17 +441,17 @@ export default function MarketingPage() { MOST POPULAR
-

AI Pro

+

AI Coach

The complete AI-driven preparation system.

-
₹2499/mo
+
₹599/mo
    -
  • Unlimited Dynamic CBTs
  • -
  • Adaptive AI DPPs
  • -
  • Interactive AI Tutor (24/7)
  • -
  • Predictive Rank Analytics
  • +
  • Everything in Pro
  • +
  • AI Spaced Repetition (Mistakes)
  • +
  • 24/7 Streaming AI Tutor Chat
  • +
  • Predictive Rank & College Predictor
- +
diff --git a/src/app/parent/dashboard/page.tsx b/src/app/parent/dashboard/page.tsx new file mode 100644 index 0000000..5e2d5c6 --- /dev/null +++ b/src/app/parent/dashboard/page.tsx @@ -0,0 +1,217 @@ +"use client" + +import { useState, useEffect } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Switch } from "@/components/ui/switch" +import { Label } from "@/components/ui/label" +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts" +import { Activity, Clock, Flame, AlertTriangle, RefreshCw } from "lucide-react" + +// Hardcoded mock UUIDs for this sprint +const PARENT_ID = "parent-123" +const STUDENT_ID = "b0e17378-864d-439e-8806-708dc48a891b" + +const DICTIONARY = { + en: { + title: "Parent Monitoring Portal", + subtitle: "Real-time analytics for your child's learning journey.", + lang_toggle: "हिंदी", + time_spent: "Weekly Time Spent", + minutes: "Minutes", + streak: "Active Streak", + days: "Days", + tests_attempted: "Tests Attempted", + tests: "This week", + accuracy_trend: "7-Day Accuracy Trend", + areas_of_concern: "Areas of Concern", + attention_needed: "Attention Needed", + pending_errors: "pending errors", + loading: "Loading child data...", + no_concerns: "No current areas of concern. Great job!" + }, + hi: { + title: "अभिभावक निगरानी पोर्टल", + subtitle: "आपके बच्चे की सीखने की यात्रा के लिए वास्तविक समय विश्लेषिकी।", + lang_toggle: "English", + time_spent: "साप्ताहिक समय व्यतीत", + minutes: "मिनट", + streak: "सक्रिय लकीर", + days: "दिन", + tests_attempted: "परीक्षण का प्रयास", + tests: "इस सप्ताह", + accuracy_trend: "7-दिवसीय सटीकता प्रवृत्ति", + areas_of_concern: "चिंता के विषय", + attention_needed: "ध्यान देने की आवश्यकता है", + pending_errors: "लंबित त्रुटियां", + loading: "बच्चे का डेटा लोड हो रहा है...", + no_concerns: "चिंता का कोई विषय नहीं है। बहुत बढ़िया!" + } +} + +export default function ParentDashboard() { + const [lang, setLang] = useState<'en' | 'hi'>('en') + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + + const t = DICTIONARY[lang] + + useEffect(() => { + async function fetchDashboard() { + try { + const res = await fetch(`http://localhost:8000/api/parent/${PARENT_ID}/dashboard/${STUDENT_ID}`) + if (!res.ok) throw new Error("Failed to fetch") + const payload = await res.json() + setData(payload) + } catch (err) { + console.error(err) + // Fallback mock data if server fails + setData({ + student_name: "Rahul", + streak_count: 5, + weekly_minutes_spent: 124.5, + total_tests_attempted_this_week: 3, + accuracy_trend: [ + { date: "Jul 01", accuracy: 40 }, + { date: "Jul 02", accuracy: 55 }, + { date: "Jul 03", accuracy: 50 }, + { date: "Jul 04", accuracy: 70 }, + { date: "Jul 05", accuracy: 65 }, + { date: "Jul 06", accuracy: 80 }, + { date: "Jul 07", accuracy: 85 } + ], + areas_of_concern: [ + { topic: "Thermodynamics", pending_errors: 4 }, + { topic: "Integration", pending_errors: 2 } + ] + }) + } finally { + setLoading(false) + } + } + fetchDashboard() + }, []) + + if (loading) { + return ( +
+ {t.loading} +
+ ) + } + + return ( +
+ + {/* Header & Language Toggle */} +
+
+

+ {t.title} +

+

{t.subtitle} — {data?.student_name}

+
+
+ + setLang(c ? 'hi' : 'en')} + /> +
+
+ + {/* Metric Blocks */} +
+ + +
+
+

{t.time_spent}

+

{data?.weekly_minutes_spent} {t.minutes}

+
+
+
+ + +
+
+

{t.streak}

+

{data?.streak_count} {t.days}

+
+
+
+ + +
+
+

{t.tests_attempted}

+

{data?.total_tests_attempted_this_week} {t.tests}

+
+
+
+
+ + {/* Main Grid */} +
+ + {/* Chart */} + + + {t.accuracy_trend} + + +
+ + + + + `${val}%`} /> + + + + +
+
+
+ + {/* Areas of Concern */} + + + + + {t.areas_of_concern} + + + + {!data?.areas_of_concern || data.areas_of_concern.length === 0 ? ( +
+

{t.no_concerns}

+
+ ) : ( + data.areas_of_concern.map((concern: any, idx: number) => ( +
+
+

{concern.topic}

+

+ {t.attention_needed} • {concern.pending_errors} {t.pending_errors} +

+
+ )) + )} +
+
+ +
+
+ ) +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 03fcacb..7ee3711 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -2,13 +2,24 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { ArrowLeft, Eye, EyeOff } from "lucide-react" +import { ArrowLeft, Eye, EyeOff, CheckCircle2 } from "lucide-react" import Link from "next/link" import Image from "next/image" import { useState } from "react" +import { useRouter } from "next/navigation" export default function SignupPage() { + const [step, setStep] = useState(1) const [showPassword, setShowPassword] = useState(false) + const [authMethod, setAuthMethod] = useState<"email" | "phone">("email") + const [showOTP, setShowOTP] = useState(false) + const router = useRouter() + + const handleNextStep = () => { + if (step === 1) setStep(2) + else router.push("/dashboard") + } + return (
@@ -29,85 +40,149 @@ export default function SignupPage() {
+ {/* Progress Bar */} +
+
+ = 1 ? "text-primary" : ""}>Account Details + = 2 ? "text-primary" : ""}>Personalize Profile +
+
+
+
+
+ - Create an account - Start your AI-powered preparation journey today. + + {step === 1 ? "Create an account" : "Complete your profile"} + + + {step === 1 ? "Start your AI-powered preparation journey today." : "Help us personalize your study plan."} + -
-
-
- - + + {step === 1 ? ( +
+
+
+ + +
+
+ + +
+ +
+ + +
+
- - + +
+ + {!showOTP && ( + + )} +
+
+ + {showOTP && ( +
+ +
+ {[1,2,3,4,5,6].map((i) => ( + + ))} +
+
+ )} + +
+ +
+ + +
-
- - + ) : ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- + )} + + + + {step === 1 && ( + <>
- - +
+ +
+
+ Or continue with +
-
-
- - - - -
-
- -
-
- Or continue with -
-
- - - - + + + )} -

- Already have an account? Log in -

+ {step === 1 ? ( +

+ Already have an account? Log in +

+ ) : ( + + )}
) diff --git a/src/app/upgrade/page.tsx b/src/app/upgrade/page.tsx new file mode 100644 index 0000000..c828086 --- /dev/null +++ b/src/app/upgrade/page.tsx @@ -0,0 +1,226 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Check, X, ShieldCheck, Loader2 } from "lucide-react" + +const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b" +const WEBHOOK_SECRET = "local_webhook_secret" + +export default function UpgradePage() { + const [loadingTier, setLoadingTier] = useState(null) + const [showModal, setShowModal] = useState(false) + const [orderData, setOrderData] = useState(null) + const [processing, setProcessing] = useState(false) + const [success, setSuccess] = useState(false) + + const handleUpgrade = async (tier: string) => { + setLoadingTier(tier) + try { + const res = await fetch(`http://localhost:8000/api/billing/create-order?user_id=${USER_ID}&plan_tier=${tier}`, { + method: "POST" + }) + if (!res.ok) throw new Error("Order creation failed") + const order = await res.json() + setOrderData({ ...order, tier }) + setShowModal(true) + } catch (err) { + console.error(err) + alert("Failed to create order") + } finally { + setLoadingTier(null) + } + } + + const generateMockSignature = async (payloadStr: string) => { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(WEBHOOK_SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(payloadStr)); + return Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + const simulatePayment = async () => { + setProcessing(true) + try { + const payloadObj = { + event: "payment.captured", + payload: { + payment: { + entity: { + id: `pay_mock_${Date.now()}`, + amount: orderData.amount, + notes: { + user_id: USER_ID, + tier_requested: orderData.tier + } + } + } + } + } + + const payloadStr = JSON.stringify(payloadObj) + const signature = await generateMockSignature(payloadStr) + + const res = await fetch(`http://localhost:8000/api/billing/webhook/razorpay`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Razorpay-Signature": signature + }, + body: payloadStr + }) + + if (!res.ok) throw new Error("Webhook failed") + + setSuccess(true) + setTimeout(() => { + window.location.href = "/dashboard" + }, 2000) + + } catch (err) { + console.error(err) + alert("Payment simulation failed") + setProcessing(false) + } + } + + return ( +
+
+

Upgrade Your Preparation

+

Unlock the full power of PragatiVeer to master JEE & NEET.

+
+ +
+ {/* Free Tier */} + + + Free + Basic access for everyone. +
₹0/mo
+
+ +
Subject-wise diagnostics
+
Basic progress tracking
+
Unlimited Tests
+
AI Spaced Repetition
+
Parent Analytics
+
+ + + +
+ + {/* Pro Tier */} + +
Most Popular
+ + Pro + For serious aspirants. +
₹299/mo
+
+ +
Everything in Free
+
Unlimited Sectional Tests
+
Performance Analytics
+
Parent Dashboard
+
AI Mistake Heatmap
+
Spaced Repetition (SM-2)
+
+ + + +
+ + {/* AI Coach Tier */} + + + AI Coach + Personalized mastery loop. +
₹599/mo
+
+ +
Everything in Pro
+
Daily AI Error Review Queue
+
SM-2 Spaced Repetition
+
Mistake Heatmap Tracking
+
AI Chat Tutor Support
+
+ + + +
+
+ + {/* Mock Razorpay Modal */} + {showModal && ( +
+ + +
+ RZP +
+ Mock Checkout Window + Razorpay Test Sandbox Simulation +
+ +
+
+ Order ID: + {orderData?.id} +
+
+ Amount: + ₹{(orderData?.amount / 100).toFixed(2)} +
+
+ Tier: + {orderData?.tier} +
+
+ + {success ? ( +
+ Payment Captured! Redirecting... +
+ ) : ( +

+ Clicking confirm will securely sign and POST a payment.captured webhook to the backend. +

+ )} +
+ + + + +
+
+ )} + +
+ ) +} diff --git a/src/components/AI/AITutorChat.tsx b/src/components/AI/AITutorChat.tsx new file mode 100644 index 0000000..c61a00c --- /dev/null +++ b/src/components/AI/AITutorChat.tsx @@ -0,0 +1,138 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Send, Bot, User, Minimize2, Maximize2, X } from "lucide-react" +import ReactMarkdown from 'react-markdown' +import remarkMath from 'remark-math' +import rehypeKatex from 'rehype-katex' +import 'katex/dist/katex.min.css' // Import Katex CSS for math rendering + +interface Message { + id: string; + role: 'user' | 'ai'; + content: string; +} + +const INITIAL_MESSAGES: Message[] = [ + { + id: '1', + role: 'ai', + content: "Hi! I noticed you struggled with **Rotational Dynamics** in the last test. Let's look at a similar problem. \n\nA solid sphere rolls without slipping. The kinetic energy is $E = \\frac{1}{2}mv^2 + \\frac{1}{2}I\\omega^2$. \n\nWhat fraction of total kinetic energy is rotational?" + }, + { + id: '2', + role: 'user', + content: "I think it is $\\frac{2}{7}$?" + }, + { + id: '3', + role: 'ai', + content: "Excellent! Yes, because $I = \\frac{2}{5}mr^2$, the rotational kinetic energy is:\n\n$$ K_{rot} = \\frac{1}{2}(\\frac{2}{5}mr^2)(\\frac{v}{r})^2 = \\frac{1}{5}mv^2 $$\n\nTotal $K = \\frac{1}{2}mv^2 + \\frac{1}{5}mv^2 = \\frac{7}{10}mv^2$.\n\nSo, $\\frac{K_{rot}}{K_{total}} = \\frac{2/10}{7/10} = \\frac{2}{7}$. You nailed it!" + } +]; + +export function AITutorChat() { + const [messages, setMessages] = useState(INITIAL_MESSAGES); + const [input, setInput] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + const handleSend = () => { + if (!input.trim()) return; + const newMessage: Message = { id: Date.now().toString(), role: 'user', content: input }; + setMessages([...messages, newMessage]); + setInput(''); + + // Mock AI response + setTimeout(() => { + setMessages(prev => [...prev, { + id: (Date.now() + 1).toString(), + role: 'ai', + content: "That's an interesting point! Let's explore the math behind it: $$\\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$" + }]); + }, 1000); + }; + + if (!isOpen) { + return ( + + ) + } + + return ( + + +
+
+ +
+
+ AI Coach +

+ Online +

+
+
+
+ + +
+
+ + + {messages.map((msg) => ( +
+
+ {msg.role === 'user' ? : } +
+ + {/* Markdown Container with Overflow Handling */} +
+
+ + {msg.content} + +
+
+
+ ))} +
+ + +
{ e.preventDefault(); handleSend(); }} className="flex w-full items-center gap-2"> + setInput(e.target.value)} + className="flex-1 bg-secondary/30 border-border focus-visible:ring-primary h-12 rounded-xl" + /> + +
+
+
+ ) +} diff --git a/src/components/AI/AdaptiveStudyPlan.tsx b/src/components/AI/AdaptiveStudyPlan.tsx new file mode 100644 index 0000000..89f4a04 --- /dev/null +++ b/src/components/AI/AdaptiveStudyPlan.tsx @@ -0,0 +1,103 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { CheckCircle2, Clock, SkipForward, BrainCircuit } from "lucide-react" + +type PlanState = 'PENDING' | 'DONE' | 'SKIPPED'; + +interface PlanItem { + id: string; + title: string; + duration: string; + type: string; + status: PlanState; +} + +const INITIAL_PLAN: PlanItem[] = [ + { id: '1', title: 'Revise Thermodynamics Concepts', duration: '45 min', type: 'Theory', status: 'PENDING' }, + { id: '2', title: 'Solve 15 Rotational Dynamics DPPs', duration: '30 min', type: 'Practice', status: 'PENDING' }, + { id: '3', title: 'Review Mistakes in Optics (T-3)', duration: '20 min', type: 'Review', status: 'PENDING' }, +]; + +export function AdaptiveStudyPlan() { + const [items, setItems] = useState(INITIAL_PLAN); + + const updateStatus = (id: string, newStatus: PlanState) => { + setItems(items.map(item => item.id === id ? { ...item, status: newStatus } : item)); + }; + + const totalTime = items.reduce((acc, curr) => { + if (curr.status === 'DONE' || curr.status === 'PENDING') { + return acc + parseInt(curr.duration); + } + return acc; + }, 0); + + return ( + + +
+ + Adaptive Study Plan + +
+ Today: {totalTime} min +
+
+

Dynamically generated based on your weak areas.

+
+ +
+ {items.map((item, index) => { + const isDone = item.status === 'DONE'; + const isSkipped = item.status === 'SKIPPED'; + + return ( +
+ {/* Timeline Icon */} +
+ {isDone ? : isSkipped ? :
} +
+ + {/* Card */} +
+
+ + {item.type} + + {item.duration} +
+

+ {item.title} +

+ + {/* Actions */} +
+ {item.status === 'PENDING' ? ( + <> + + + + ) : ( +
+ {isDone ? 'Completed' : 'Rescheduled'} +
+ )} +
+
+
+ ) + })} +
+ + + ) +} diff --git a/src/components/AI/CollegePredictorCard.tsx b/src/components/AI/CollegePredictorCard.tsx new file mode 100644 index 0000000..b51cc38 --- /dev/null +++ b/src/components/AI/CollegePredictorCard.tsx @@ -0,0 +1,84 @@ +"use client" + +import { useEffect, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { GraduationCap, MapPin, RefreshCw } from "lucide-react" + +const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b" + +export function CollegePredictorCard() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + async function fetchReadiness() { + try { + const res = await fetch(`http://localhost:8000/api/ai-coach/readiness/${USER_ID}`) + if (!res.ok) throw new Error("Failed to fetch") + const json = await res.json() + setData(json) + } catch (err) { + console.warn("Using mock fallback data for College Predictor.") + setData({ + college_prediction: "NIT Trichy / NIT Surathkal on track.", + prediction_category: "Target" + }) + } finally { + setLoading(false) + } + } + fetchReadiness() + }, []) + + if (loading) { + return ( + + + + ) + } + + const prediction = data?.college_prediction || "Keep Practicing" + const category = data?.prediction_category || "State Level" + + const getCategoryColor = (cat: string) => { + switch(cat.toUpperCase()) { + case "REACH": return "bg-purple-500/10 text-purple-600 border-purple-500/20" + case "TARGET": return "bg-blue-500/10 text-blue-600 border-blue-500/20" + case "SAFETY": return "bg-green-500/10 text-green-600 border-green-500/20" + default: return "bg-secondary text-muted-foreground border-transparent" + } + } + + return ( + +
+ +
+ + + +
+ + Admissions Radar +
+ + {category} + +
+ AI-projected admission trajectory. +
+ + +
+

+ "{prediction}" +

+
+

+ Prediction updates dynamically after every test. +

+
+
+ ) +} diff --git a/src/components/AI/MistakeHeatmap.tsx b/src/components/AI/MistakeHeatmap.tsx new file mode 100644 index 0000000..8901a00 --- /dev/null +++ b/src/components/AI/MistakeHeatmap.tsx @@ -0,0 +1,80 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Flame, Info } from "lucide-react" + +const HEATMAP_DATA = [ + { topic: "Rotational Dynamics", attempts: [80, 90, 40, 20, 10] }, // 10 is critical error rate + { topic: "Electromagnetism", attempts: [30, 25, 20, 15, 10] }, + { topic: "Thermodynamics", attempts: [5, 10, 5, 2, 0] }, + { topic: "Optics", attempts: [60, 50, 45, 60, 55] }, + { topic: "Modern Physics", attempts: [10, 5, 2, 0, 0] } +]; + +export function MistakeHeatmap() { + const getColor = (errorRate: number) => { + if (errorRate >= 50) return 'bg-red-500/80 border-red-500 shadow-[0_0_10px_-2px_rgba(239,68,68,0.5)]'; // Critical + if (errorRate >= 30) return 'bg-amber-500/60 border-amber-500'; // Warning + if (errorRate >= 10) return 'bg-yellow-500/30 border-yellow-500/50'; // Moderate + return 'bg-green-500/20 border-green-500/30'; // Good + }; + + return ( + + +
+ + Mistake Heatmap + + +
+

Topic-wise error rate across last 5 tests.

+
+ +
+
+ {/* Header row */} +
+
Topic
+
+ T-4 + T-3 + T-2 + T-1 + Latest +
+
+ + {/* Data rows */} +
+ {HEATMAP_DATA.map((row, idx) => ( +
+
+ {row.topic} +
+
+ {row.attempts.map((errRate, i) => ( +
+ {errRate}% +
+ ))} +
+
+ ))} +
+
+
+ + {/* Legend */} +
+
{"< 10%"}
+
{"10-30%"}
+
{"30-50%"}
+
{"> 50%"}
+
+
+
+ ) +} diff --git a/src/components/AI/MistakeReviewCard.tsx b/src/components/AI/MistakeReviewCard.tsx new file mode 100644 index 0000000..ac110f6 --- /dev/null +++ b/src/components/AI/MistakeReviewCard.tsx @@ -0,0 +1,169 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { CheckCircle2, XCircle, BrainCircuit } from "lucide-react" +import ReactMarkdown from 'react-markdown' +import remarkMath from 'remark-math' +import rehypeKatex from 'rehype-katex' +import 'katex/dist/katex.min.css' + +export interface MistakePayload { + mistake: { + id: string + user_id: string + question_id: string + error_type: string + topic: string + subtopic: string + review_count: number + consecutive_correct: number + is_mastered: boolean + } + question: { + id: string + subject: string + question_text: string + options: Record + correct_answer: { correct: string } + solution_text: string + } +} + +interface MistakeReviewCardProps { + data: MistakePayload + onNext: () => void + onSubmitReview: (mistakeId: string, selectedAnswer: string) => Promise<{is_correct: boolean, correct_answer: string}> +} + +export function MistakeReviewCard({ data, onNext, onSubmitReview }: MistakeReviewCardProps) { + const [selected, setSelected] = useState(null) + const [result, setResult] = useState<'success' | 'error' | null>(null) + const [correctAnswer, setCorrectAnswer] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleCheck = async () => { + if (!selected) return; + setIsSubmitting(true) + + try { + const res = await onSubmitReview(data.mistake.id, selected) + setResult(res.is_correct ? 'success' : 'error') + setCorrectAnswer(res.correct_answer) + } catch (err) { + console.error(err) + } finally { + setIsSubmitting(false) + } + } + + const handleNext = () => { + setSelected(null) + setResult(null) + setCorrectAnswer(null) + onNext() + } + + const { mistake, question } = data; + + return ( + +
+
+ + Attempt {mistake.review_count + 1} + + {mistake.topic} • {mistake.subtopic} +
+
+ 0 ? "text-amber-500" : ""} /> + Consecutive Correct: {mistake.consecutive_correct}/3 before mastery +
+
+ + +
+ + {question.question_text} + +
+ +
+ {Object.entries(question.options).map(([letter, text]) => { + const isSelected = selected === letter + + let btnClass = "border-border bg-background hover:border-primary/50 hover:bg-secondary/20" + let iconClass = "border-border text-muted-foreground group-hover:border-primary/50 group-hover:text-primary" + + if (result) { + // Post-check styling + if (letter === correctAnswer) { + btnClass = "border-green-500 bg-green-500/10 shadow-[0_0_15px_-5px_#22c55e]" + iconClass = "border-green-500 bg-green-500 text-white" + } else if (isSelected && letter !== correctAnswer) { + btnClass = "border-red-500 bg-red-500/10" + iconClass = "border-red-500 bg-red-500 text-white" + } + } else if (isSelected) { + btnClass = "border-primary bg-primary/5 shadow-[0_0_15px_-5px_var(--color-primary)]" + iconClass = "border-primary bg-primary text-primary-foreground" + } + + return ( + + ) + })} +
+ +
+ {!result ? ( + + ) : ( + + )} +
+ + {/* Solution Block */} + {result && ( +
+

AI Coach Explanation

+
+ + {question.solution_text} + +
+
+ )} +
+
+ ) +} diff --git a/src/components/AI/ProgressShare.tsx b/src/components/AI/ProgressShare.tsx new file mode 100644 index 0000000..50590a9 --- /dev/null +++ b/src/components/AI/ProgressShare.tsx @@ -0,0 +1,64 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Download, Image as ImageIcon, Loader2 } from "lucide-react" +import { useState } from "react" + +export function ProgressShare() { + const [downloading, setDownloading] = useState(false) + + // Example dynamic params + const streak = "12" + const accuracy = "85" + const name = "Student" + const imageUrl = `/api/og/progress?streak=${streak}&accuracy=${accuracy}&name=${name}` + + const handleDownload = async () => { + try { + setDownloading(true) + const res = await fetch(imageUrl) + const blob = await res.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.style.display = "none" + a.href = url + a.download = "PragatiVeer_Progress.png" + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + } catch (err) { + console.error("Failed to download image", err) + } finally { + setDownloading(false) + } + } + + return ( + + + + + Share Progress + + Auto-generated dynamic scorecard. + + + +
+ Progress Scorecard +
+ + +
+
+ ) +} diff --git a/src/components/AI/ReadinessGauge.tsx b/src/components/AI/ReadinessGauge.tsx new file mode 100644 index 0000000..63e9630 --- /dev/null +++ b/src/components/AI/ReadinessGauge.tsx @@ -0,0 +1,120 @@ +"use client" + +import { useEffect, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Activity, RefreshCw } from "lucide-react" + +const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b" + +export function ReadinessGauge() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + async function fetchReadiness() { + try { + const res = await fetch(`http://localhost:8000/api/ai-coach/readiness/${USER_ID}`) + if (!res.ok) throw new Error("Failed to fetch") + const json = await res.json() + setData(json) + } catch (err) { + console.warn("Using mock fallback data for Readiness Gauge.") + setData({ + score: 72, + breakdown: { + accuracy: 30, + speed: 15, + consistency: 8, + recovery: 5, + coverage: 14 + } + }) + } finally { + setLoading(false) + } + } + fetchReadiness() + }, []) + + if (loading) { + return ( + + + + ) + } + + const score = data?.score || 0 + const breakdown = data?.breakdown || {} + const circumference = 2 * Math.PI * 45 // r=45 + const strokeDashoffset = circumference - (score / 100) * circumference + + const getColor = (s: number) => { + if (s >= 75) return "text-green-500" + if (s >= 50) return "text-yellow-500" + return "text-red-500" + } + + return ( + + + + + Exam Readiness + + Real-time AI composite score. + + + + + {/* SVG Circular Gauge */} +
+ + {/* Background Ring */} + + {/* Progress Ring */} + + +
+ {score} + / 100 +
+
+ + {/* Breakdown Bars */} +
+ {[ + { label: "Accuracy", value: breakdown.accuracy, max: 40 }, + { label: "Speed", value: breakdown.speed, max: 20 }, + { label: "Coverage", value: breakdown.coverage, max: 20 }, + { label: "Consistency", value: breakdown.consistency, max: 10 }, + { label: "Recovery", value: breakdown.recovery, max: 10 }, + ].map((item, idx) => ( +
+
+ {item.label} + {item.value} / {item.max} +
+
+
+
+
+ ))} +
+ + + + ) +} diff --git a/src/components/AI/ReferralCard.tsx b/src/components/AI/ReferralCard.tsx new file mode 100644 index 0000000..6741f5b --- /dev/null +++ b/src/components/AI/ReferralCard.tsx @@ -0,0 +1,72 @@ +"use client" + +import { useEffect, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Link as LinkIcon, Share2, Check, Gift } from "lucide-react" + +const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b" + +export function ReferralCard() { + const [data, setData] = useState(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + async function fetchReferral() { + try { + const res = await fetch(`http://localhost:8000/api/growth/referral/${USER_ID}`) + if (res.ok) { + const json = await res.json() + setData(json) + } + } catch (err) { + console.warn("Using mock fallback data for Referral Card.") + } + } + fetchReferral() + }, []) + + const link = data?.referral_link || "https://pragativeer.com/join/demo" + const message = data?.share_message || "Join me on PragatiVeer! Use my link to get 7 days of the AI Coach for free." + + const copyToClipboard = () => { + navigator.clipboard.writeText(link) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const shareToWhatsApp = () => { + const waUrl = `https://wa.me/?text=${encodeURIComponent(message)}` + window.open(waUrl, "_blank") + } + + return ( + +
+ +
+ + + + + Invite & Earn + + Give 7 days of AI Coach, get 7 days free. + + + +
+ {link} + +
+ + +
+
+ ) +} diff --git a/src/components/AI/StudyPlanCard.tsx b/src/components/AI/StudyPlanCard.tsx new file mode 100644 index 0000000..abf3bbc --- /dev/null +++ b/src/components/AI/StudyPlanCard.tsx @@ -0,0 +1,131 @@ +"use client" + +import { useEffect, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { CalendarClock, CheckCircle2, Circle, Clock, Loader2, PlayCircle, RefreshCw, XCircle } from "lucide-react" + +const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b" + +export function StudyPlanCard() { + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + async function fetchPlan() { + try { + const res = await fetch(`http://localhost:8000/api/ai-coach/study-plan/today?user_id=${USER_ID}`) + if (!res.ok) throw new Error("No plan") + const data = await res.json() + setTasks(data.tasks || []) + } catch (err) { + console.error(err) + // Mock fallback if celery hasn't seeded yet + setTasks([ + { type: "Revision", description: "Revise Thermodynamics", duration_minutes: 15, status: "Pending" }, + { type: "Mistake_Review", description: "Daily Mistake Review Session", duration_minutes: 10, status: "Pending" }, + { type: "DPS", description: "Attempt Thermodynamics Practice Sheet", duration_minutes: 20, status: "Pending" } + ]) + } finally { + setLoading(false) + } + } + fetchPlan() + }, []) + + const toggleTask = (idx: number, newStatus: string) => { + setTasks(prev => { + const copy = [...prev] + copy[idx] = { ...copy[idx], status: newStatus } + return copy + }) + } + + return ( + + +
+ + + Today's Plan + + + {tasks.reduce((acc, curr) => acc + curr.duration_minutes, 0)} min total + +
+ AI-generated curriculum targeting your weak spots. +
+ + {loading ? ( +
+ Loading schedule... +
+ ) : ( +
+ {tasks.map((task, idx) => { + const isDone = task.status === "Done" + const isSkipped = task.status === "Skipped" + const isPending = task.status === "Pending" + + return ( +
+
+
+ {task.type} + + {task.duration_minutes} min + +
+

+ {task.description} +

+
+ + {/* Controls */} +
+ {isPending && ( + <> + + + + )} + {(isDone || isSkipped) && ( + + )} +
+
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/src/components/AI/TutorChat.tsx b/src/components/AI/TutorChat.tsx new file mode 100644 index 0000000..616729e --- /dev/null +++ b/src/components/AI/TutorChat.tsx @@ -0,0 +1,143 @@ +"use client" + +import { useState, useRef, useEffect } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Send, Bot, User, Loader2 } from "lucide-react" +import ReactMarkdown from "react-markdown" +import remarkMath from "remark-math" +import rehypeKatex from "rehype-katex" +import "katex/dist/katex.min.css" + +const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b" + +interface Message { + role: "user" | "assistant" + content: string +} + +export function TutorChat() { + const [messages, setMessages] = useState([ + { role: "assistant", content: "Hello! I am your PragatiVeer AI Coach. What topic would you like to review today?" } + ]) + const [input, setInput] = useState("") + const [streaming, setStreaming] = useState(false) + const messagesEndRef = useRef(null) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const handleSend = async (e?: React.FormEvent) => { + e?.preventDefault() + if (!input.trim() || streaming) return + + const userMsg = input.trim() + setInput("") + setMessages(prev => [...prev, { role: "user", content: userMsg }]) + + // Add empty assistant message placeholder + setMessages(prev => [...prev, { role: "assistant", content: "" }]) + setStreaming(true) + + try { + const res = await fetch("http://localhost:8000/api/ai-coach/chat/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: USER_ID, message: userMsg }) + }) + + if (!res.ok) { + throw new Error("Backend streaming failed. Using mock fallback.") + } + + if (!res.body) throw new Error("No readable stream") + + const reader = res.body.getReader() + const decoder = new TextDecoder() + + while (true) { + const { done, value } = await reader.read() + if (done) break + const chunk = decoder.decode(value, { stream: true }) + setMessages(prev => { + const newMessages = [...prev] + newMessages[newMessages.length - 1].content += chunk + return newMessages + }) + } + } catch (err) { + console.warn("Falling back to mock stream due to error:", err) + // Mock Fallback Streaming logic + const mockResponse = "It looks like my backend connection is restricted right now. However, I can still render math for you! Here is the quadratic formula:\n\n$$ x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} $$\n\nAnd inline math: $E = mc^2$." + const words = mockResponse.split(" ") + + for (let i = 0; i < words.length; i++) { + await new Promise(resolve => setTimeout(resolve, 50)) // Simulate network delay + setMessages(prev => { + const newMessages = [...prev] + newMessages[newMessages.length - 1].content += (i === 0 ? "" : " ") + words[i] + return newMessages + }) + } + } finally { + setStreaming(false) + } + } + + return ( + + + + + AI Tutor Chat + + + + + {messages.map((msg, idx) => ( +
+
+ {msg.role === "assistant" && } +
+ {msg.role === "assistant" ? ( + + {msg.content || "..."} + + ) : ( + msg.content + )} +
+
+
+ ))} +
+ + +
+
+ setInput(e.target.value)} + placeholder="Ask me to explain a concept..." + className="flex-1 bg-secondary/50 border-transparent focus-visible:ring-primary" + disabled={streaming} + /> + +
+
+ + ) +} diff --git a/src/components/CBT/Calculator.tsx b/src/components/CBT/Calculator.tsx new file mode 100644 index 0000000..4e651ae --- /dev/null +++ b/src/components/CBT/Calculator.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { X, Delete } from "lucide-react"; + +interface CalculatorProps { + isOpen: boolean; + onClose: () => void; +} + +export function Calculator({ isOpen, onClose }: CalculatorProps) { + const [display, setDisplay] = useState(""); + + if (!isOpen) return null; + + const handleInput = (val: string) => { + setDisplay((prev) => prev + val); + }; + + const handleCalculate = () => { + try { + // Map functions to JS Math + let mappedExp = display + .replace(/sin\(/g, 'Math.sin(') + .replace(/cos\(/g, 'Math.cos(') + .replace(/tan\(/g, 'Math.tan(') + .replace(/log\(/g, 'Math.log10(') + .replace(/ln\(/g, 'Math.log(') + .replace(/sqrt\(/g, 'Math.sqrt(') + .replace(/\^/g, '**'); + + const result = new Function('return ' + mappedExp)(); + + // Handle floating point precision issues simply + if (typeof result === 'number' && !Number.isInteger(result)) { + setDisplay(String(parseFloat(result.toFixed(6)))); + } else { + setDisplay(String(result)); + } + } catch { + setDisplay("Error"); + } + }; + + const handleClear = () => setDisplay(""); + const handleBackspace = () => setDisplay((prev) => prev.slice(0, -1)); + + return ( +
+
+
+

Scientific Calculator

+ +
+
+
+ {display || "0"} +
+
+ {/* Scientific Row 1 */} + + + + + + + {/* Scientific Row 2 */} + + + + + + + {/* Number Row 1 */} + + + + + + + {/* Number Row 2 */} + + + + + + + {/* Number Row 3 */} + + + + + + + {/* Number Row 4 */} +
+ + + + + {/* Equals */} + +
+
+
+
+ ); +} diff --git a/src/components/CBT/ExamResult.tsx b/src/components/CBT/ExamResult.tsx new file mode 100644 index 0000000..d0b75cb --- /dev/null +++ b/src/components/CBT/ExamResult.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { CheckCircle2, XCircle, AlertCircle, Clock, Award, ArrowRight, RotateCcw } from "lucide-react"; +import Link from 'next/link'; + +interface ExamAttemptPayload { + score: number; + total_questions: number; + accuracy: number; + time_taken: number; + answers: Array<{ + question_id: string; + selected_answer: string | null; + is_correct: boolean; + }>; +} + +interface ExamResultProps { + payload: ExamAttemptPayload; + onRetake: () => void; +} + +export function ExamResult({ payload, onRetake }: ExamResultProps) { + const answered = payload.answers.filter(a => a.selected_answer !== null).length; + const unanswered = payload.total_questions - answered; + + // Calculate correct based on score formula (+4 for correct, -1 for wrong). + // Wait, payload score is just correct count according to seed script right now, let's assume it. + const correctCount = payload.answers.filter(a => a.is_correct).length; + const incorrectCount = answered - correctCount; + + // Real score calculation: +4 correct, -1 incorrect + const finalScore = (correctCount * 4) - (incorrectCount * 1); + const maxPossibleScore = payload.total_questions * 4; + + const formatTime = (seconds: number) => { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${h}h ${m}m ${s}s`; + return `${m}m ${s}s`; + }; + + return ( +
+
+ +
+
+ +
+

Exam Successfully Submitted

+

Here is a quick snapshot of your performance.

+
+ +
+ + + Final Score +
+ {finalScore} / {maxPossibleScore} +
+
+
+ + + + Accuracy +
+ {Math.round(payload.accuracy)}% +
+
+
+ + + + Time Taken +
+ {formatTime(payload.time_taken)} +
+
+
+ + + + Attempted +
+ {answered} / {payload.total_questions} +
+
+
+
+ +
+
+ +
{correctCount}
+
Correct
+
+
+ +
{incorrectCount}
+
Incorrect
+
+
+ +
{unanswered}
+
Unanswered
+
+
+ +
+ + + + +
+ +
+
+ ); +} diff --git a/src/components/CBT/PeriodicTable.tsx b/src/components/CBT/PeriodicTable.tsx new file mode 100644 index 0000000..7bd7e9f --- /dev/null +++ b/src/components/CBT/PeriodicTable.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { X, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import Image from "next/image"; + +interface PeriodicTableProps { + isOpen: boolean; + onClose: () => void; +} + +export function PeriodicTable({ isOpen, onClose }: PeriodicTableProps) { + if (!isOpen) return null; + + return ( +
+
+
+

Periodic Table of Elements

+
+ + +
+
+
+ {/* We will use a high quality remote SVG/image for the periodic table reference */} +
+ {/* Using a reliable wikimedia SVG for Periodic Table as a reference */} + Periodic Table +
+
+
+
+ ); +} diff --git a/src/components/CBT/Scratchpad.tsx b/src/components/CBT/Scratchpad.tsx new file mode 100644 index 0000000..6177102 --- /dev/null +++ b/src/components/CBT/Scratchpad.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { X, Eraser, Pen } from "lucide-react"; + +interface ScratchpadProps { + isOpen: boolean; + onClose: () => void; +} + +export function Scratchpad({ isOpen, onClose }: ScratchpadProps) { + const canvasRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const [mode, setMode] = useState<"pen" | "eraser">("pen"); + const [notes, setNotes] = useState(""); + + // Initialize and load saved session memory + useEffect(() => { + if (isOpen) { + const savedNotes = localStorage.getItem("cbt_scratchpad_notes"); + if (savedNotes) setNotes(savedNotes); + + if (canvasRef.current) { + const canvas = canvasRef.current; + canvas.width = 500; + canvas.height = 500; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = "white"; + ctx.lineWidth = 2; + + const savedDrawing = localStorage.getItem("cbt_scratchpad_drawing"); + if (savedDrawing) { + const img = new window.Image(); + img.onload = () => { + ctx.drawImage(img, 0, 0); + }; + img.src = savedDrawing; + } + } + } + } + }, [isOpen]); + + if (!isOpen) return null; + + const startDrawing = (e: React.MouseEvent | React.TouchEvent) => { + setIsDrawing(true); + draw(e); + }; + + const stopDrawing = () => { + setIsDrawing(false); + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext("2d"); + ctx?.beginPath(); + localStorage.setItem("cbt_scratchpad_drawing", canvas.toDataURL()); + } + }; + + const draw = (e: React.MouseEvent | React.TouchEvent) => { + if (!isDrawing || !canvasRef.current) return; + const canvas = canvasRef.current; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let clientX, clientY; + if ('touches' in e) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else { + clientX = (e as React.MouseEvent).clientX; + clientY = (e as React.MouseEvent).clientY; + } + + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + + ctx.lineTo(x, y); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x, y); + }; + + const clearCanvas = () => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (ctx && canvas) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + localStorage.removeItem("cbt_scratchpad_drawing"); + } + }; + + const clearNotes = () => { + setNotes(""); + localStorage.removeItem("cbt_scratchpad_notes"); + }; + + const handleModeSwitch = (newMode: "pen" | "eraser") => { + setMode(newMode); + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (ctx) { + if (newMode === "eraser") { + ctx.globalCompositeOperation = "destination-out"; + ctx.lineWidth = 20; + } else { + ctx.globalCompositeOperation = "source-over"; + ctx.lineWidth = 2; + } + } + }; + + const handleNotesChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setNotes(val); + localStorage.setItem("cbt_scratchpad_notes", val); + }; + + return ( +
+
+
+

Split Workspace: Draw & Type

+
+ + + +
+
+
+ {/* Drawing Column */} +
+
+ +
+
+ +
+
+ {/* Typing Column */} +
+
+ +
+
+