diff --git a/backend/database.py b/backend/database.py index e4186cb..dbf38e7 100644 --- a/backend/database.py +++ b/backend/database.py @@ -4,8 +4,9 @@ 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) +connect_args = {"options": "-c timezone=Asia/Kolkata"} if "postgresql" in DATABASE_URL else {} +# Using the standard SQLModel engine config for PostgreSQL with IST timezone session +engine = create_engine(DATABASE_URL, echo=True, connect_args=connect_args) def init_db(): # Automatically generates tables based on the classes imported from models.py diff --git a/docker-compose.yml b/docker-compose.yml index fcba7d3..d8c47aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: POSTGRES_USER: pragativeer_admin POSTGRES_PASSWORD: local_secure_password_2026 POSTGRES_DB: pragativeer_prod + TZ: Asia/Kolkata ports: - "5432:5432" volumes: @@ -79,6 +80,7 @@ services: ports: - "8000:8000" environment: + - TZ=Asia/Kolkata - DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod - REDIS_URL=redis://redis:6379/0 - ENV=development @@ -108,6 +110,7 @@ services: volumes: - ./backend:/app environment: + - TZ=Asia/Kolkata - DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod - REDIS_URL=redis://redis:6379/0 - MINIO_ENDPOINT=minio:9000 @@ -135,6 +138,7 @@ services: volumes: - ./backend:/app environment: + - TZ=Asia/Kolkata - DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod - REDIS_URL=redis://redis:6379/0 env_file: @@ -160,6 +164,7 @@ services: ports: - "3000:3000" environment: + - TZ=Asia/Kolkata - NEXT_PUBLIC_API_URL=http://backend:8000 - NEXT_PUBLIC_PAYMENT_GATEWAY=razorpay depends_on: diff --git a/src/app/admin/tests/page.tsx b/src/app/admin/tests/page.tsx index 318f105..b23a708 100644 --- a/src/app/admin/tests/page.tsx +++ b/src/app/admin/tests/page.tsx @@ -18,6 +18,7 @@ import { z } from "zod" import { useActiveUser } from "@/hooks/useActiveUser" import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter" +import { parseAsISTDate, formatISTDateTime, toISTISOString } from "@/lib/dateUtils" // Permissive, auto-normalizing Zod schema for client-side question parsing const QuestionSchema = z.preprocess((val: any) => { @@ -188,8 +189,8 @@ 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) + setEditScheduledStart(parseAsISTDate(test.scheduled_date)) + setEditScheduledEnd(parseAsISTDate(test.end_date)) setEditIsActive(test.is_active ?? true) } @@ -202,8 +203,8 @@ export default function AdminTestsPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: editTitle, - scheduled_date: editScheduledStart ? editScheduledStart.toISOString() : null, - end_date: editScheduledEnd ? editScheduledEnd.toISOString() : null, + scheduled_date: toISTISOString(editScheduledStart), + end_date: toISTISOString(editScheduledEnd), is_active: editIsActive }) }) @@ -342,8 +343,8 @@ export default function AdminTestsPage() { formData.append("exam_pattern", examPattern) 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()) + if (scheduledDate) formData.append("scheduled_date", toISTISOString(scheduledDate)!) + if (endDate) formData.append("end_date", toISTISOString(endDate)!) const res = await fetchWithAuth("/api/admin/tests/upload", { method: "POST", @@ -382,8 +383,8 @@ export default function AdminTestsPage() { time_limit: parseInt(timeLimit), admin_id: effectiveAdminId, questions_json: validQuestions, - scheduled_date: scheduledDate ? scheduledDate.toISOString() : null, - end_date: endDate ? endDate.toISOString() : null, + scheduled_date: toISTISOString(scheduledDate), + end_date: toISTISOString(endDate), }; try { @@ -436,10 +437,7 @@ export default function AdminTestsPage() { }, [tests, adminTypeFilter, adminDateFilter]) const formatScheduleDateTime = (dateStr: string) => { - return new Date(dateStr).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short" - }) + return formatISTDateTime(dateStr) } return ( @@ -681,8 +679,8 @@ export default function AdminTestsPage() {
diff --git a/src/app/dashboard/progress/page.tsx b/src/app/dashboard/progress/page.tsx index 29fb465..58b74fb 100644 --- a/src/app/dashboard/progress/page.tsx +++ b/src/app/dashboard/progress/page.tsx @@ -252,7 +252,27 @@ export default function ProgressTrackerPage() { useEffect(() => { setSubjects(getInitialSubjects(isDemo, user?.target_exam || "JEE_Main")); - }, [isDemo, user?.target_exam]); + + const handleSwitched = (e: any) => { + const switchedUser = e.detail?.user; + const switchedId = e.detail?.userId; + const exam = switchedUser?.target_exam || (switchedId === "33333333-3333-3333-3333-333333333333" ? "JEE_NEET" : "JEE_Main"); + const demo = isDemoUser(switchedId); + setSubjects(getInitialSubjects(demo, exam)); + }; + + const handleUpdated = () => { + setSubjects(getInitialSubjects(isDemoUser(userId), user?.target_exam || "JEE_Main")); + }; + + window.addEventListener("pragativeer-user-switched", handleSwitched); + window.addEventListener("pragativeer-user-updated", handleUpdated); + + return () => { + window.removeEventListener("pragativeer-user-switched", handleSwitched); + window.removeEventListener("pragativeer-user-updated", handleUpdated); + }; + }, [isDemo, user?.target_exam, userId]); if (!hasAccessTo("chapter-wise-progress-tracking") && !isDemo) { return ( diff --git a/src/components/CalendarFilter.tsx b/src/components/CalendarFilter.tsx index baa1e9a..7c2802c 100644 --- a/src/components/CalendarFilter.tsx +++ b/src/components/CalendarFilter.tsx @@ -13,15 +13,11 @@ export interface CalendarFilterProps { align?: "left" | "right" } -/** Helper to format any date safely in local time as "YYYY-MM-DD" */ +import { getISTDateString } from "@/lib/dateUtils" + +/** Helper to format any date safely in IST (Asia/Kolkata) 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}` + return getISTDateString(dateInput) } export function CalendarFilter({ diff --git a/src/hooks/useActiveUser.ts b/src/hooks/useActiveUser.ts index da4fd0a..8c45472 100644 --- a/src/hooks/useActiveUser.ts +++ b/src/hooks/useActiveUser.ts @@ -361,7 +361,23 @@ export function useActiveUser() { if (typeof window === "undefined") return; const handleUpdate = () => fetchUserProfile(activeUserIdRef.current); + const handleSwitched = (e: any) => { + const switchedId = e.detail?.userId; + const switchedUser = e.detail?.user; + if (switchedId) { + activeUserIdRef.current = switchedId; + setUserIdState(switchedId); + if (switchedUser) { + cachedUserProfile = switchedUser; + setUser(switchedUser); + } else { + fetchUserProfile(switchedId); + } + } + }; + window.addEventListener("pragativeer-user-updated", handleUpdate); + window.addEventListener("pragativeer-user-switched", handleSwitched); // Poll every 30s (less aggressive, kinder on backend) const interval = setInterval(() => { @@ -370,6 +386,7 @@ export function useActiveUser() { return () => { window.removeEventListener("pragativeer-user-updated", handleUpdate); + window.removeEventListener("pragativeer-user-switched", handleSwitched); clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/lib/dateUtils.ts b/src/lib/dateUtils.ts new file mode 100644 index 0000000..067f0de --- /dev/null +++ b/src/lib/dateUtils.ts @@ -0,0 +1,108 @@ +/** + * Unified Date & Time utilities configured for Indian Standard Time (IST, Asia/Kolkata). + * Pragativeer is an Indian EdTech platform where all test and DPP scheduling, + * calendar bounds, and student-facing timestamps operate strictly in IST. + */ + +export const TIMEZONE_IST = "Asia/Kolkata"; + +/** + * Safely parse any date input (string, Date, or ISO string) into a Date object. + * If the string lacks an explicit timezone offset or Zulu indicator, it is + * treated as an Indian Standard Time (IST) timestamp (+05:30) so that server + * environments running in UTC do not shift the time by 5h 30m. + */ +export function parseAsISTDate(dateInput: string | Date | null | undefined): Date | null { + if (!dateInput) return null; + if (dateInput instanceof Date) { + return isNaN(dateInput.getTime()) ? null : dateInput; + } + let str = String(dateInput).trim(); + if (!str) return null; + + // If string has no timezone indicator (no 'Z' and no explicit +HH:MM or -HH:MM): + // e.g. "2026-10-24T05:30:00" or "2026-10-24 05:30:00" + if (!str.endsWith("Z") && !/[+-]\d{2}:?\d{2}$/.test(str)) { + str = str.replace(" ", "T"); + str = `${str}+05:30`; + } + const d = new Date(str); + return isNaN(d.getTime()) ? null : d; +} + +/** + * Formats a date in IST as "Oct 24, 2026, 5:30 AM" + */ +export function formatISTDateTime(dateInput: string | Date | null | undefined): string { + const d = parseAsISTDate(dateInput); + if (!d) return ""; + return d.toLocaleString("en-US", { + timeZone: TIMEZONE_IST, + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +/** + * Formats a date in IST as "Oct 24, 2026" + */ +export function formatISTDate(dateInput: string | Date | null | undefined): string { + const d = parseAsISTDate(dateInput); + if (!d) return ""; + return d.toLocaleDateString("en-US", { + timeZone: TIMEZONE_IST, + month: "short", + day: "numeric", + year: "numeric", + }); +} + +/** + * Formats a date in IST as "5:30 AM" or "5:30 PM" + */ +export function formatISTTime(dateInput: string | Date | null | undefined): string { + const d = parseAsISTDate(dateInput); + if (!d) return ""; + return d.toLocaleTimeString("en-US", { + timeZone: TIMEZONE_IST, + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +/** + * Returns a "YYYY-MM-DD" date string evaluated in Indian Standard Time (Asia/Kolkata). + * Useful for CalendarFilter matching and date grouping without UTC day boundary shifts. + */ +export function getISTDateString(dateInput: string | Date | null | undefined): string { + const d = parseAsISTDate(dateInput); + if (!d) return ""; + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: TIMEZONE_IST, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return fmt.format(d); +} + +/** + * Converts a Date object picked in DatePicker into an ISO-8601 string + * explicitly tagged with the IST offset (+05:30) so the backend and database + * preserve the exact hour and minute selected by the admin. + */ +export function toISTISOString(date: Date | null | undefined): string | null { + if (!date || isNaN(date.getTime())) return null; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + const hh = String(date.getHours()).padStart(2, "0"); + const mm = String(date.getMinutes()).padStart(2, "0"); + const ss = String(date.getSeconds()).padStart(2, "0"); + return `${y}-${m}-${d}T${hh}:${mm}:${ss}+05:30`; +}