From 19edf8368409dc7eb4e17da56ec798bfa0bd937b Mon Sep 17 00:00:00 2001 From: ectasy663 Date: Fri, 23 Oct 2026 12:23:36 +0530 Subject: [PATCH] feat: admin date-time calendar controller, dpp ui cleanup, 4-subject jee+neet tracker, calendar filter, and clean new account state --- backend/main.py | 43 +- src/app/admin/tests/page.tsx | 540 ++++++++++++++++---- src/app/dashboard/ai-coach/cbt-dpp/page.tsx | 139 +++-- src/app/dashboard/ai-coach/tutor/page.tsx | 2 +- src/app/dashboard/cbt/page.tsx | 77 ++- src/app/dashboard/dpp/page.tsx | 142 +++-- src/app/dashboard/layout.tsx | 8 +- src/app/dashboard/progress/page.tsx | 52 +- src/components/CalendarFilter.tsx | 321 ++++++++++++ src/hooks/useActiveUser.ts | 19 +- 10 files changed, 1143 insertions(+), 200 deletions(-) create mode 100644 src/components/CalendarFilter.tsx diff --git a/backend/main.py b/backend/main.py index 366a2a1..0106046 100644 --- a/backend/main.py +++ b/backend/main.py @@ -899,8 +899,8 @@ DEMO_DEFINITIONS = { "state": "Bihar", "school": "Loyola High School", "tier": models.TierEnum.FREE, - "streak_count": 1, - "longest_streak": 1, + "streak_count": 0, + "longest_streak": 0, # trial_end is intentionally omitted here β€” it is set dynamically at creation time }, } @@ -1110,7 +1110,6 @@ DEMO_USER_UUIDS = { "11111111-1111-1111-1111-111111111111", # Aarav (AI Coach - JEE) "22222222-2222-2222-2222-222222222222", # Neha (AI Coach - NEET) "33333333-3333-3333-3333-333333333333", # Rohan (AI Coach - JEE+NEET) - "44444444-4444-4444-4444-444444444444", # Amit (Free - JEE) } @app.get("/api/users/{user_id}/stats", tags=["Users"]) @@ -1343,6 +1342,7 @@ async def upload_admin_test( exam_pattern: str = Form(...), time_limit: int = Form(...), scheduled_date: Optional[datetime] = Form(None), + end_date: Optional[datetime] = Form(None), admin_id: Optional[UUID] = Form(None), session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user), @@ -1388,6 +1388,7 @@ async def upload_admin_test( total_time=time_limit, difficulty="Medium", scheduled_date=scheduled_date, + end_date=end_date, is_active=True, created_by=effective_admin_id, pdf_url=url @@ -1534,6 +1535,42 @@ def toggle_test_status(test_id: UUID, session: Session = Depends(get_session), c return {"status": "success", "is_active": test_obj.is_active} +class UpdateTestScheduleInput(BaseModel): + scheduled_date: Optional[datetime] = None + end_date: Optional[datetime] = None + title: Optional[str] = None + is_active: Optional[bool] = None + +@app.put("/api/admin/tests/{test_id}/schedule", tags=["Admin"]) +def update_test_schedule( + test_id: UUID, + data: UpdateTestScheduleInput, + session: Session = Depends(get_session), + current_user: models.User = Depends(get_current_user) +): + test_obj = session.get(models.Test, test_id) + if not test_obj: + raise HTTPException(status_code=404, detail="Test not found") + + test_obj.scheduled_date = data.scheduled_date + test_obj.end_date = data.end_date + if data.title is not None: + test_obj.title = data.title + if data.is_active is not None: + test_obj.is_active = data.is_active + session.add(test_obj) + session.commit() + session.refresh(test_obj) + + return { + "status": "success", + "test_id": test_obj.id, + "title": test_obj.title, + "scheduled_date": test_obj.scheduled_date, + "end_date": test_obj.end_date, + "is_active": test_obj.is_active + } + @app.delete("/api/admin/tests/{test_id}", tags=["Admin"]) def delete_test(test_id: UUID, session: Session = Depends(get_session), current_user: models.User = Depends(get_current_user)): from sqlmodel import select diff --git a/src/app/admin/tests/page.tsx b/src/app/admin/tests/page.tsx index 05203b6..1eeba54 100644 --- a/src/app/admin/tests/page.tsx +++ b/src/app/admin/tests/page.tsx @@ -1,18 +1,23 @@ "use client" import { fetchWithAuth } from "@/lib/apiClient"; -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useRef, useMemo } from "react" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Switch } from "@/components/ui/switch" import { Input } from "@/components/ui/input" -import { Loader2, Upload, FileText, Clock, CalendarIcon, CheckCircle2, AlertCircle, X, Trash2 } from "lucide-react" +import { + Loader2, Upload, FileText, Clock, Calendar as CalendarIcon, + CheckCircle2, AlertCircle, X, Trash2, CalendarRange, + SlidersHorizontal, Check, RefreshCw, Zap, ShieldAlert +} from "lucide-react" import DatePicker from "react-datepicker" import "react-datepicker/dist/react-datepicker.css" import { z } from "zod" import { useActiveUser } from "@/hooks/useActiveUser" +import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter" // Permissive, auto-normalizing Zod schema for client-side question parsing const QuestionSchema = z.preprocess((val: any) => { @@ -119,6 +124,18 @@ export default function AdminTestsPage() { const [validationErrors, setValidationErrors] = useState<{ path: string, message: string }[]>([]) const fileInputRef = useRef(null) + // Calendar-based controller state + const [adminDateFilter, setAdminDateFilter] = useState("") + const [adminTypeFilter, setAdminTypeFilter] = useState("All") + + // Schedule Editing Modal State + const [editingTest, setEditingTest] = useState(null) + const [editTitle, setEditTitle] = useState("") + const [editScheduledStart, setEditScheduledStart] = useState(null) + const [editScheduledEnd, setEditScheduledEnd] = useState(null) + const [editIsActive, setEditIsActive] = useState(true) + const [savingSchedule, setSavingSchedule] = useState(false) + const fetchTests = async () => { try { const res = await fetchWithAuth("/api/admin/tests") @@ -134,7 +151,6 @@ export default function AdminTestsPage() { } useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect fetchTests() }, []) @@ -169,6 +185,78 @@ export default function AdminTestsPage() { } } + const openScheduleModal = (test: any) => { + setEditingTest(test) + setEditTitle(test.title || "") + setEditScheduledStart(test.scheduled_date ? new Date(test.scheduled_date) : null) + setEditScheduledEnd(test.end_date ? new Date(test.end_date) : null) + setEditIsActive(test.is_active ?? true) + } + + const handleSaveSchedule = async () => { + if (!editingTest) return + setSavingSchedule(true) + try { + const res = await fetchWithAuth(`/api/admin/tests/${editingTest.id}/schedule`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: editTitle, + scheduled_date: editScheduledStart ? editScheduledStart.toISOString() : null, + end_date: editScheduledEnd ? editScheduledEnd.toISOString() : null, + is_active: editIsActive + }) + }) + if (res.ok) { + setEditingTest(null) + fetchTests() + } else { + const err = await res.json() + alert(`Failed to update schedule: ${JSON.stringify(err)}`) + } + } catch (e) { + console.error(e) + alert("Error saving schedule changes.") + } finally { + setSavingSchedule(false) + } + } + + // Quick preset actions for calendar schedule controller + const setLiveNowPreset = () => { + const now = new Date() + const end = new Date(Date.now() + 24 * 3600 * 1000) + setEditScheduledStart(now) + setEditScheduledEnd(end) + setEditIsActive(true) + } + + const setTodayPreset = () => { + const start = new Date() + start.setHours(9, 0, 0, 0) + const end = new Date() + end.setHours(23, 59, 59, 999) + setEditScheduledStart(start) + setEditScheduledEnd(end) + setEditIsActive(true) + } + + const setTomorrowPreset = () => { + const start = new Date(Date.now() + 86400000) + start.setHours(9, 0, 0, 0) + const end = new Date(Date.now() + 86400000) + end.setHours(23, 59, 59, 999) + setEditScheduledStart(start) + setEditScheduledEnd(end) + setEditIsActive(true) + } + + const clearScheduleBounds = () => { + setEditScheduledStart(null) + setEditScheduledEnd(null) + setEditIsActive(true) + } + const handleDragOver = (e: React.DragEvent) => { e.preventDefault() setIsDragging(true) @@ -255,6 +343,7 @@ export default function AdminTestsPage() { formData.append("time_limit", timeLimit) formData.append("admin_id", effectiveAdminId) if (scheduledDate) formData.append("scheduled_date", scheduledDate.toISOString()) + if (endDate) formData.append("end_date", endDate.toISOString()) const res = await fetchWithAuth("/api/admin/tests/upload", { method: "POST", @@ -263,6 +352,8 @@ export default function AdminTestsPage() { if (res.ok) { alert("PDF uploaded successfully to Minio and connected!") setTitle("") + setScheduledDate(null) + setEndDate(null) removeFile() fetchTests() } else { @@ -305,6 +396,8 @@ export default function AdminTestsPage() { if (res.ok) { alert("Test created successfully!") setTitle("") + setScheduledDate(null) + setEndDate(null) removeFile() fetchTests() } else { @@ -319,50 +412,84 @@ export default function AdminTestsPage() { } } + // All scheduled dates for CalendarFilter dots + const allScheduledDates = useMemo(() => { + return tests + .map(t => t.scheduled_date ? getLocalDateString(t.scheduled_date) : "") + .filter(Boolean) + }, [tests]) + + // Filter tests by Type and Schedule Date + const filteredTests = useMemo(() => { + return tests.filter(test => { + if (adminTypeFilter !== "All") { + if (adminTypeFilter === "DPP_PDF" && !test.pdf_url) return false + if (adminTypeFilter === "DPS" && (test.type !== "DPS" || test.pdf_url)) return false + if (adminTypeFilter === "Mock" && test.type !== "Mock") return false + } + if (adminDateFilter) { + if (!test.scheduled_date) return false + if (getLocalDateString(test.scheduled_date) !== adminDateFilter) return false + } + return true + }) + }, [tests, adminTypeFilter, adminDateFilter]) + + const formatScheduleDateTime = (dateStr: string) => { + return new Date(dateStr).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short" + }) + } + return (
-
-

Tests & DPPs

-

Manage CBT examinations and daily practice sheets

+
+
+

Tests & DPPs Manager

+

+ Date-wise calendar scheduling, live time-bound controllers, and examination repositories. +

+
- {/* Upload Form */} + {/* Upload & Create Form */} - Create Manual Test + Create / Schedule Test - Upload a structured JSON array of questions in LaTeX format to create a deterministic test. + Upload structured question JSON or attach a class DPP PDF with exact calendar timings.
- - setTitle(e.target.value)} placeholder="e.g. JEE Main Mock Test 1" /> + + setTitle(e.target.value)} placeholder="e.g. Physics Ch-4 DPP (Electrostatics)" />
- {/* TEST TYPE β€” KEY DIFFERENTIATOR */} + {/* TEST TYPE */}

- {testType === 'Mock' && 'βœ… Purely for CBT. Appears under Dashboard β†’ Mock Tests.'} - {testType === 'DPS' && 'βœ… Purely for CBT. Appears under AI Coach β†’ PrepPilot AI.'} - {testType === 'DPP_PDF' && 'βœ… Uploads a PDF to Minio storage for Class/Plan aligned DPP.'} + {testType === 'Mock' && 'βœ… Appears in Student Dashboard β†’ Mock Tests (CBT).'} + {testType === 'DPS' && 'βœ… Appears in Student Dashboard β†’ AI Coach β†’ PrepPilot AI.'} + {testType === 'DPP_PDF' && 'βœ… Appears in Student Dashboard β†’ Daily Practice (DPP).'}

@@ -376,6 +503,7 @@ export default function AdminTestsPage() { +
@@ -384,43 +512,48 @@ export default function AdminTestsPage() { setTimeLimit(e.target.value)} />
-
-
- - setScheduledDate(date)} - showTimeSelect - timeFormat="HH:mm" - timeIntervals={15} - timeCaption="time" - dateFormat="MMMM d, yyyy h:mm aa" - className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" - placeholderText="Optional" - /> + {/* Schedule Boundation Controller */} +
+
+ Schedule Boundation (Start & End)
-
- - setEndDate(date)} - showTimeSelect - timeFormat="HH:mm" - timeIntervals={15} - timeCaption="time" - dateFormat="MMMM d, yyyy h:mm aa" - className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" - placeholderText="Optional" - /> +
+
+ + setScheduledDate(date)} + showTimeSelect + timeFormat="HH:mm" + timeIntervals={15} + timeCaption="Time" + dateFormat="MMM d, yyyy h:mm aa" + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ring" + placeholderText="Starts Immediately" + /> +
+
+ + setEndDate(date)} + showTimeSelect + timeFormat="HH:mm" + timeIntervals={15} + timeCaption="Time" + dateFormat="MMM d, yyyy h:mm aa" + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ring" + placeholderText="No Expiry" + /> +
- + {testType === 'DPP_PDF' ? ( -
- +
{err.message} ))} - {validationErrors.length > 50 && ( -
  • ...and {validationErrors.length - 50} more errors
  • - )}
    )} @@ -485,75 +615,285 @@ export default function AdminTestsPage() { - {/* Existing Tests List */} + {/* Existing Tests List & Calendar Controller */} - - - Test Repository - - Manage visibility and schedule for parsed tests. +
    +
    + + + Scheduled Tests & DPPs Controller + + Filter by date, adjust start/end time windows, and manage visibility. +
    + +
    + + {/* Filter Controllers Toolbar */} +
    +
    + + +
    + {["All", "DPP_PDF", "DPS", "Mock"].map((t) => ( + + ))} +
    +
    + +
    + Showing {filteredTests.length} of {tests.length} items +
    +
    {loading ? (
    - ) : tests.length === 0 ? ( -
    - No tests uploaded yet. + ) : filteredTests.length === 0 ? ( +
    + No tests or DPPs match the selected filter criteria.
    ) : ( -
    - {tests.map(test => ( -
    -
    -
    -

    {test.title}

    - {test.exam_pattern?.replace("_", " ") || "All"} - - {test.is_active ? "Active" : "Inactive / Processing"} - -
    -
    -
    - - {test.total_time} mins -
    - {test.scheduled_date && ( -
    - - {new Date(test.scheduled_date).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })} +
    + {filteredTests.map(test => { + const now = new Date() + const startTime = test.scheduled_date ? new Date(test.scheduled_date) : null + const endTime = test.end_date ? new Date(test.end_date) : null + const isUpcoming = startTime ? startTime > now : false + const isExpired = endTime ? now > endTime : false + const isLive = !isUpcoming && !isExpired + + return ( +
    +
    +
    +
    +

    {test.title}

    + + {test.pdf_url ? "DPP (PDF)" : test.type === "DPS" ? "PrepPilot (DPS)" : "CBT Mock"} + + + {test.exam_pattern?.replace("_", " ") || "All"} + + + {/* Live status pill */} + {isUpcoming ? ( + + πŸ”’ Upcoming + + ) : isExpired ? ( + + ⏳ Expired + + ) : ( + + 🟒 Live Now + + )} + + + {test.is_active ? "Published" : "Hidden"} +
    - )} + +
    +
    + + {test.total_time} mins +
    + + {test.scheduled_date ? ( +
    + Starts: {formatScheduleDateTime(test.scheduled_date)} +
    + ) : ( + Always Available + )} + + {test.end_date && ( +
    + Ends: {formatScheduleDateTime(test.end_date)} +
    + )} +
    +
    + + {/* Actions */} +
    + +
    + handleToggle(test.id)} title="Toggle published status" /> +
    + +
    -
    -
    - {test.is_active ? "Published" : "Hidden"} - handleToggle(test.id)} /> -
    - -
    -
    - ))} + ) + })}
    )}
    + + {/* SCHEDULE CONTROLLER MODAL */} + {editingTest && ( +
    +
    +
    +
    +
    + +
    +
    +

    Edit Time Boundation & Schedule

    +

    {editingTest.title}

    +
    +
    + +
    + +
    +
    + + setEditTitle(e.target.value)} /> +
    + + {/* Quick Preset Buttons */} +
    + +
    + + + + +
    +
    + + {/* Precise Date/Time Pickers */} +
    +
    + + setEditScheduledStart(date)} + showTimeSelect + timeFormat="HH:mm" + timeIntervals={15} + timeCaption="Time" + dateFormat="MMM d, yyyy h:mm aa" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-bold" + placeholderText="Immediate / Open" + /> + When access opens for students +
    +
    + + setEditScheduledEnd(date)} + showTimeSelect + timeFormat="HH:mm" + timeIntervals={15} + timeCaption="Time" + dateFormat="MMM d, yyyy h:mm aa" + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-bold" + placeholderText="No Expiry" + /> + When test expires / closes +
    +
    + + {/* Status Switch */} +
    +
    +

    Published / Active

    +

    Students can see this test when scheduled

    +
    + +
    +
    + +
    + + +
    +
    +
    + )}
    ) } - diff --git a/src/app/dashboard/ai-coach/cbt-dpp/page.tsx b/src/app/dashboard/ai-coach/cbt-dpp/page.tsx index 3974586..3a774ca 100644 --- a/src/app/dashboard/ai-coach/cbt-dpp/page.tsx +++ b/src/app/dashboard/ai-coach/cbt-dpp/page.tsx @@ -5,9 +5,10 @@ import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { BrainCircuit, Play, CheckCircle2, Download, Filter, Lock, FileText, Eye, ShieldAlert, X, Sparkles, Share2, Maximize2, Minimize2, Zap } from "lucide-react" -import { useEffect, useState } from "react" +import { useEffect, useState, useMemo } from "react" import Link from "next/link" import { useActiveUser } from "@/hooks/useActiveUser" +import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter" export default function DPPPage() { const { user, userId, hasAccessTo } = useActiveUser() @@ -80,20 +81,42 @@ export default function DPPPage() { return acc; }, {} as Record); + const scheduledDates = useMemo(() => { + return dpps + .filter((d) => !d.pdf_url && !!d.scheduled_date) + .map((d) => getLocalDateString(d.scheduled_date)) + .filter(Boolean) + }, [dpps]) + const filteredDpps = dpps.filter(dpp => { // Only show CBT DPPs here (no PDF) if (dpp.pdf_url) return false; if (dateFilter) { if (!dpp.scheduled_date) return false; - const scheduledDate = new Date(dpp.scheduled_date).toISOString().split('T')[0]; + const scheduledDate = getLocalDateString(dpp.scheduled_date); if (scheduledDate !== dateFilter) return false; } return true; }); - const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' }); + const formatDateTime = (dateStr: string) => { + return new Date(dateStr).toLocaleString("en-US", { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } + + const formatTime = (dateStr: string) => { + return new Date(dateStr).toLocaleTimeString("en-US", { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); } const getPdfUrl = (title: string) => { @@ -124,23 +147,19 @@ export default function DPPPage() {
    -
    -
    - setDateFilter(e.target.value)} - className="px-3 py-1.5 bg-background border border-border rounded-lg text-xs font-bold focus:ring-2 focus:ring-[var(--cyan-ai)] outline-none text-muted-foreground mr-2" +
    +
    + - Difficulty: - {['Mixed', 'Easy', 'Medium', 'Hard'].map((diff) => ( - - ))} -
    - Pro -
    + {dateFilter && ( + + Showing for {dateFilter} + + )}
    + ) : isExpired && !isAttempted ? ( - + ) : isAttempted ? ( <> @@ -256,7 +320,8 @@ export default function DPPPage() {
    - )})} + ); + })}
    {/* ── PROTECTED PDF READER MODAL (FREE TIER) ── */} diff --git a/src/app/dashboard/ai-coach/tutor/page.tsx b/src/app/dashboard/ai-coach/tutor/page.tsx index ff75360..74f1b71 100644 --- a/src/app/dashboard/ai-coach/tutor/page.tsx +++ b/src/app/dashboard/ai-coach/tutor/page.tsx @@ -5,7 +5,7 @@ import { BrainCircuit, Sparkles } from "lucide-react" export default function TutorChatPage() { return ( -
    +
    diff --git a/src/app/dashboard/cbt/page.tsx b/src/app/dashboard/cbt/page.tsx index 85cc071..081a712 100644 --- a/src/app/dashboard/cbt/page.tsx +++ b/src/app/dashboard/cbt/page.tsx @@ -10,6 +10,9 @@ import { Activity, Clock, PlayCircle, Search, Lock, CheckSquare } from "lucide-r import { useActiveUser } from "@/hooks/useActiveUser"; import { PremiumGate } from "@/components/PremiumGate"; +import { useMemo } from "react"; +import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"; + interface TestData { id: string; title: string; @@ -91,21 +94,51 @@ export default function CBTPage() { loadTests(); }, [userId, selectedPattern]); + const scheduledDates = useMemo(() => { + return exams + .filter((e) => !!e.scheduled_date) + .map((e) => getLocalDateString(e.scheduled_date)) + .filter(Boolean) + }, [exams]); + const filteredExams = exams.filter(exam => { const matchesSearch = exam.title.toLowerCase().includes(search.toLowerCase()); if (!matchesSearch) return false; if (dateFilter) { if (!exam.scheduled_date) return false; - const scheduledDate = new Date(exam.scheduled_date).toISOString().split('T')[0]; + const scheduledDate = getLocalDateString(exam.scheduled_date); if (scheduledDate !== dateFilter) return false; } return true; }); + const formatDateTime = (dateStr: string) => { + return new Date(dateStr).toLocaleString("en-US", { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } + const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString("en-US", { year: 'numeric', month: 'short', day: 'numeric' }); + return new Date(dateStr).toLocaleDateString("en-US", { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + } + + const formatTime = (dateStr: string) => { + return new Date(dateStr).toLocaleTimeString("en-US", { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); } // Get the highest score for each test if attempted multiple times @@ -143,15 +176,13 @@ export default function CBTPage() { Real exam environment simulations to conquer {selectedPattern === "NEET" ? "NEET" : (targetExam || "JEE")}.

    -
    -
    - setDateFilter(e.target.value)} - className="w-full px-4 py-2 bg-background border border-border rounded-xl text-sm focus:ring-2 focus:ring-[var(--cyan-ai)] outline-none text-foreground dark:[color-scheme:dark] [color-scheme:light]" - /> -
    +
    +
    - {isUpcoming ? `Available on ${formatDate(exam.scheduled_date!)}` : isExpired ? `Expired on ${formatDate(exam.end_date!)}` : 'Available Now'} + {isUpcoming && exam.scheduled_date ? ( + + Opens: {formatDateTime(exam.scheduled_date)} + + ) : isExpired && exam.end_date ? ( + + Expired on {formatDateTime(exam.end_date)} + + ) : exam.end_date ? ( + + Live Now β€’ Closes {formatDateTime(exam.end_date)} + + ) : exam.scheduled_date ? ( + + Available since {formatDateTime(exam.scheduled_date)} + + ) : ( + Available Now + )}
    {exam.total_time} Min β€’ {exam.difficulty} Difficulty
    {isUpcoming ? ( - ) : isLocked ? (
    -
    -
    -
    - setDateFilter(e.target.value)} - className="px-3 py-1.5 bg-background border border-border rounded-lg text-xs font-bold focus:ring-2 focus:ring-[var(--cyan-ai)] outline-none text-muted-foreground mr-2" + {/* Calendar Filter & Controller Bar */} +
    +
    + - + {dateFilter && ( + + Showing DPPs for {dateFilter} + + )} +
    +
    + {filteredDpps.length} {filteredDpps.length === 1 ? "Practice Sheet" : "Practice Sheets"} Available
    -
    {loading ? (
    Loading AI-generated DPPs...
    ) : filteredDpps.length === 0 ? ( -
    No DPPs available for the selected criteria.
    +
    + No DPPs found for the selected schedule criteria. +
    ) : filteredDpps.map((dpp, i) => { // Calculate total questions dynamically from sections let count = 0; @@ -159,22 +188,32 @@ export default function AIDailyPracticePage() { const attempt = attemptsByTest[dpp.id]; const isAttempted = !!attempt; - // Date logic + // Accurate Date & Time logic const now = new Date(); - const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : now; + const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : null; const endTime = dpp.end_date ? new Date(dpp.end_date) : null; - const isUpcoming = scheduledTime > now; + const isUpcoming = scheduledTime ? scheduledTime > now : false; const isExpired = endTime ? now > endTime : false; + const isLive = !isUpcoming && !isExpired; return ( - +
    -
    - {isAttempted ? :
    } + {/* Clean Status Icon β€” No Selectable Circle Checkboxes */} +
    + {isAttempted ? ( +
    + +
    + ) : ( +
    + +
    + )}
    -
    +
    {user?.target_exam?.replace("_", " ") || "JEE/NEET"} @@ -183,14 +222,30 @@ export default function AIDailyPracticePage() { {isUpcoming ? ( <> β€’ - Upcoming + + πŸ”’ Upcoming + ) : isExpired ? ( <> β€’ - Expired + + ⏳ Expired + - ) : null} + ) : ( + <> + β€’ + + 🟒 Live Now + + + )} + {isAttempted && ( + + Attempted + + )}

    {cleanTitle(dpp.title)} @@ -201,10 +256,25 @@ export default function AIDailyPracticePage() {

    - {count} Questions β€’ {dpp.total_time} mins - {dpp.scheduled_date && ( - - {isUpcoming ? `Available on ${formatDate(dpp.scheduled_date)}` : isExpired ? `Expired on ${formatDate(dpp.end_date!)}` : 'Available Now'} + {count} Questions β€’ {dpp.total_time} mins + {isUpcoming && scheduledTime && ( + + Opens on {formatDateTime(dpp.scheduled_date)} + + )} + {isExpired && endTime && ( + + Expired on {formatDateTime(dpp.end_date)} + + )} + {isLive && endTime && ( + + Closes on {formatDateTime(dpp.end_date)} + + )} + {isLive && !endTime && scheduledTime && ( + + Available since {formatDateTime(dpp.scheduled_date)} )}
    @@ -212,9 +282,13 @@ export default function AIDailyPracticePage() {
    {isUpcoming ? ( - + ) : isExpired && !isAttempted ? ( - + ) : ( hasAccessTo('PRO') ? (
    -
    +
    = 4 ? 'lg:grid-cols-4' : 'md:grid-cols-3'} gap-6 mb-8`}> {subjects.map((sub, i) => ( diff --git a/src/components/CalendarFilter.tsx b/src/components/CalendarFilter.tsx new file mode 100644 index 0000000..7f4b08e --- /dev/null +++ b/src/components/CalendarFilter.tsx @@ -0,0 +1,321 @@ +"use client" + +import React, { useState, useRef, useEffect } from "react" +import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, X, Sparkles, Check } from "lucide-react" +import { Button } from "@/components/ui/button" + +export interface CalendarFilterProps { + selectedDate: string // Format: "YYYY-MM-DD" or "" + onSelectDate: (date: string) => void + availableDates?: string[] // Optional array of "YYYY-MM-DD" + placeholder?: string + className?: string + align?: "left" | "right" +} + +/** Helper to format any date safely in local time as "YYYY-MM-DD" */ +export function getLocalDateString(dateInput: string | Date | null | undefined): string { + if (!dateInput) return "" + const d = typeof dateInput === "string" ? new Date(dateInput) : dateInput + if (isNaN(d.getTime())) return "" + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, "0") + const day = String(d.getDate()).padStart(2, "0") + return `${y}-${m}-${day}` +} + +export function CalendarFilter({ + selectedDate, + onSelectDate, + availableDates = [], + placeholder = "Filter by Date", + className = "", + align = "left", +}: CalendarFilterProps) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + // Current view year & month for calendar navigation + const today = new Date() + const [viewYear, setViewYear] = useState(() => { + if (selectedDate) { + const parts = selectedDate.split("-").map(Number) + if (parts[0]) return parts[0] + } + return today.getFullYear() + }) + + const [viewMonth, setViewMonth] = useState(() => { + if (selectedDate) { + const parts = selectedDate.split("-").map(Number) + if (parts[1]) return parts[1] - 1 + } + return today.getMonth() + }) + + // Sync view when selectedDate changes + useEffect(() => { + if (selectedDate) { + const parts = selectedDate.split("-").map(Number) + if (parts[0] && parts[1]) { + setViewYear(parts[0]) + setViewMonth(parts[1] - 1) + } + } + }, [selectedDate]) + + // Click outside listener + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside) + } + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [isOpen]) + + const prevMonth = (e: React.MouseEvent) => { + e.stopPropagation() + if (viewMonth === 0) { + setViewMonth(11) + setViewYear((y) => y - 1) + } else { + setViewMonth((m) => m - 1) + } + } + + const nextMonth = (e: React.MouseEvent) => { + e.stopPropagation() + if (viewMonth === 11) { + setViewMonth(0) + setViewYear((y) => y + 1) + } else { + setViewMonth((m) => m + 1) + } + } + + const handleSelectDay = (day: number) => { + const formatted = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}` + if (selectedDate === formatted) { + onSelectDate("") + } else { + onSelectDate(formatted) + } + setIsOpen(false) + } + + const setQuickDate = (dateStr: string) => { + onSelectDate(dateStr) + setIsOpen(false) + } + + // Days calculations + const firstDayOfWeek = new Date(viewYear, viewMonth, 1).getDay() // 0 = Sun + const totalDaysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate() + const monthNames = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ] + + const todayStr = getLocalDateString(today) + const tomorrowStr = getLocalDateString(new Date(Date.now() + 86400000)) + + const formatDisplay = (dateStr: string) => { + if (!dateStr) return placeholder + const parts = dateStr.split("-").map(Number) + if (parts.length === 3) { + const d = new Date(parts[0], parts[1] - 1, parts[2]) + if (dateStr === todayStr) { + return `Today (${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })})` + } + if (dateStr === tomorrowStr) { + return `Tomorrow (${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })})` + } + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + } + return dateStr + } + + // Available dates set for quick lookup + const availableSet = new Set(availableDates.map((d) => d.split("T")[0])) + + return ( +
    + {/* Trigger Button */} +
    + + + {selectedDate && ( + + )} +
    + + {/* Popover Calendar */} + {isOpen && ( +
    + {/* Quick presets */} +
    + + + +
    + + {/* Month / Year Navigation */} +
    +

    + {monthNames[viewMonth]} {viewYear} +

    +
    + + +
    +
    + + {/* Days of Week Header */} +
    + {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => ( + + {d} + + ))} +
    + + {/* Days Grid */} +
    + {Array.from({ length: firstDayOfWeek }).map((_, i) => ( +
    + ))} + + {Array.from({ length: totalDaysInMonth }).map((_, i) => { + const day = i + 1 + const currentDayStr = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}` + const isSelected = selectedDate === currentDayStr + const isToday = currentDayStr === todayStr + const hasItems = availableSet.has(currentDayStr) + + return ( + + ) + })} +
    + + {/* Footer stats if availableDates exists */} + {availableDates.length > 0 && ( +
    + + + Scheduled Tests Available + + {selectedDate && ( + + )} +
    + )} +
    + )} +
    + ) +} diff --git a/src/hooks/useActiveUser.ts b/src/hooks/useActiveUser.ts index 6e22962..dd1c353 100644 --- a/src/hooks/useActiveUser.ts +++ b/src/hooks/useActiveUser.ts @@ -28,17 +28,30 @@ export const DEMO_USERS = { AICOACH_JEE: { id: "11111111-1111-1111-1111-111111111111", label: "AI Coach - JEE" }, AICOACH_NEET: { id: "22222222-2222-2222-2222-222222222222", label: "AI Coach - NEET" }, AICOACH_JEE_NEET: { id: "33333333-3333-3333-3333-333333333333", label: "AI Coach - JEE+NEET" }, - FREE_JEE: { id: "44444444-4444-4444-4444-444444444444", label: "Free - JEE" }, + FREE_JEE: { id: "44444444-4444-4444-4444-444444444444", label: "Free - JEE (New/Raw)" }, }; -// IDs considered "demo" accounts β€” demo switcher only shows for these +// Showcase accounts that have pre-filled mock activity data for demonstration +export const SHOWCASE_DEMO_IDS = new Set([ + DEMO_USERS.AICOACH_JEE.id, + DEMO_USERS.AICOACH_NEET.id, + DEMO_USERS.AICOACH_JEE_NEET.id, + "usr_01", "usr_02", "usr_03" +]); + +// IDs allowed in the demo switcher bar export const DEMO_IDS = new Set([ ...Object.values(DEMO_USERS).map(u => u.id), "usr_01", "usr_02", "usr_03", "usr_04", "usr_05" ]); -/** Returns true if the userId matches a known demo account */ +/** Returns true if the userId is a showcase account with pre-filled mock activity data */ export function isDemoUser(userId?: string | null): boolean { + return !!userId && SHOWCASE_DEMO_IDS.has(userId); +} + +/** Returns true if the userId is eligible for demo switcher dropdown */ +export function isDemoAccount(userId?: string | null): boolean { return !!userId && DEMO_IDS.has(userId); }