fix: resolve IST/UTC timezone scheduling delta and enable syllabus tracker hot-reload on JEE+NEET switch
This commit is contained in:
@@ -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")
|
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
|
connect_args = {"options": "-c timezone=Asia/Kolkata"} if "postgresql" in DATABASE_URL else {}
|
||||||
engine = create_engine(DATABASE_URL, echo=True)
|
# 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():
|
def init_db():
|
||||||
# Automatically generates tables based on the classes imported from models.py
|
# Automatically generates tables based on the classes imported from models.py
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ services:
|
|||||||
POSTGRES_USER: pragativeer_admin
|
POSTGRES_USER: pragativeer_admin
|
||||||
POSTGRES_PASSWORD: local_secure_password_2026
|
POSTGRES_PASSWORD: local_secure_password_2026
|
||||||
POSTGRES_DB: pragativeer_prod
|
POSTGRES_DB: pragativeer_prod
|
||||||
|
TZ: Asia/Kolkata
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -79,6 +80,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=Asia/Kolkata
|
||||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- ENV=development
|
- ENV=development
|
||||||
@@ -108,6 +110,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=Asia/Kolkata
|
||||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- MINIO_ENDPOINT=minio:9000
|
- MINIO_ENDPOINT=minio:9000
|
||||||
@@ -135,6 +138,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=Asia/Kolkata
|
||||||
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
- DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@pgbouncer:5432/pragativeer_prod
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
env_file:
|
env_file:
|
||||||
@@ -160,6 +164,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=Asia/Kolkata
|
||||||
- NEXT_PUBLIC_API_URL=http://backend:8000
|
- NEXT_PUBLIC_API_URL=http://backend:8000
|
||||||
- NEXT_PUBLIC_PAYMENT_GATEWAY=razorpay
|
- NEXT_PUBLIC_PAYMENT_GATEWAY=razorpay
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { z } from "zod"
|
|||||||
|
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
||||||
|
import { parseAsISTDate, formatISTDateTime, toISTISOString } from "@/lib/dateUtils"
|
||||||
|
|
||||||
// Permissive, auto-normalizing Zod schema for client-side question parsing
|
// Permissive, auto-normalizing Zod schema for client-side question parsing
|
||||||
const QuestionSchema = z.preprocess((val: any) => {
|
const QuestionSchema = z.preprocess((val: any) => {
|
||||||
@@ -188,8 +189,8 @@ export default function AdminTestsPage() {
|
|||||||
const openScheduleModal = (test: any) => {
|
const openScheduleModal = (test: any) => {
|
||||||
setEditingTest(test)
|
setEditingTest(test)
|
||||||
setEditTitle(test.title || "")
|
setEditTitle(test.title || "")
|
||||||
setEditScheduledStart(test.scheduled_date ? new Date(test.scheduled_date) : null)
|
setEditScheduledStart(parseAsISTDate(test.scheduled_date))
|
||||||
setEditScheduledEnd(test.end_date ? new Date(test.end_date) : null)
|
setEditScheduledEnd(parseAsISTDate(test.end_date))
|
||||||
setEditIsActive(test.is_active ?? true)
|
setEditIsActive(test.is_active ?? true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,8 +203,8 @@ export default function AdminTestsPage() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: editTitle,
|
title: editTitle,
|
||||||
scheduled_date: editScheduledStart ? editScheduledStart.toISOString() : null,
|
scheduled_date: toISTISOString(editScheduledStart),
|
||||||
end_date: editScheduledEnd ? editScheduledEnd.toISOString() : null,
|
end_date: toISTISOString(editScheduledEnd),
|
||||||
is_active: editIsActive
|
is_active: editIsActive
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -342,8 +343,8 @@ export default function AdminTestsPage() {
|
|||||||
formData.append("exam_pattern", examPattern)
|
formData.append("exam_pattern", examPattern)
|
||||||
formData.append("time_limit", timeLimit)
|
formData.append("time_limit", timeLimit)
|
||||||
formData.append("admin_id", effectiveAdminId)
|
formData.append("admin_id", effectiveAdminId)
|
||||||
if (scheduledDate) formData.append("scheduled_date", scheduledDate.toISOString())
|
if (scheduledDate) formData.append("scheduled_date", toISTISOString(scheduledDate)!)
|
||||||
if (endDate) formData.append("end_date", endDate.toISOString())
|
if (endDate) formData.append("end_date", toISTISOString(endDate)!)
|
||||||
|
|
||||||
const res = await fetchWithAuth("/api/admin/tests/upload", {
|
const res = await fetchWithAuth("/api/admin/tests/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -382,8 +383,8 @@ export default function AdminTestsPage() {
|
|||||||
time_limit: parseInt(timeLimit),
|
time_limit: parseInt(timeLimit),
|
||||||
admin_id: effectiveAdminId,
|
admin_id: effectiveAdminId,
|
||||||
questions_json: validQuestions,
|
questions_json: validQuestions,
|
||||||
scheduled_date: scheduledDate ? scheduledDate.toISOString() : null,
|
scheduled_date: toISTISOString(scheduledDate),
|
||||||
end_date: endDate ? endDate.toISOString() : null,
|
end_date: toISTISOString(endDate),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -436,10 +437,7 @@ export default function AdminTestsPage() {
|
|||||||
}, [tests, adminTypeFilter, adminDateFilter])
|
}, [tests, adminTypeFilter, adminDateFilter])
|
||||||
|
|
||||||
const formatScheduleDateTime = (dateStr: string) => {
|
const formatScheduleDateTime = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleString("en-US", {
|
return formatISTDateTime(dateStr)
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short"
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -681,8 +679,8 @@ export default function AdminTestsPage() {
|
|||||||
<div className="space-y-3.5">
|
<div className="space-y-3.5">
|
||||||
{filteredTests.map(test => {
|
{filteredTests.map(test => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const startTime = test.scheduled_date ? new Date(test.scheduled_date) : null
|
const startTime = parseAsISTDate(test.scheduled_date)
|
||||||
const endTime = test.end_date ? new Date(test.end_date) : null
|
const endTime = parseAsISTDate(test.end_date)
|
||||||
const isUpcoming = startTime ? startTime > now : false
|
const isUpcoming = startTime ? startTime > now : false
|
||||||
const isExpired = endTime ? now > endTime : false
|
const isExpired = endTime ? now > endTime : false
|
||||||
const isLive = !isUpcoming && !isExpired
|
const isLive = !isUpcoming && !isExpired
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Scratchpad } from "@/components/CBT/Scratchpad"
|
|||||||
import { PeriodicTable } from "@/components/CBT/PeriodicTable"
|
import { PeriodicTable } from "@/components/CBT/PeriodicTable"
|
||||||
import { ExamResult } from "@/components/CBT/ExamResult"
|
import { ExamResult } from "@/components/CBT/ExamResult"
|
||||||
import { fetchWithAuth } from "@/lib/apiClient"
|
import { fetchWithAuth } from "@/lib/apiClient"
|
||||||
|
import { parseAsISTDate, formatISTDateTime } from "@/lib/dateUtils"
|
||||||
|
|
||||||
// Dummy data for testing the CBT client engine
|
// Dummy data for testing the CBT client engine
|
||||||
const DUMMY_QUESTIONS = Array.from({ length: 20 }).map((_, i) => ({
|
const DUMMY_QUESTIONS = Array.from({ length: 20 }).map((_, i) => ({
|
||||||
@@ -103,15 +104,17 @@ export default function CBTExamPage({ params }: { params: Promise<{ examId: stri
|
|||||||
if (!testRes.ok) throw new Error("Failed to fetch test structure");
|
if (!testRes.ok) throw new Error("Failed to fetch test structure");
|
||||||
const testData = await testRes.json();
|
const testData = await testRes.json();
|
||||||
|
|
||||||
// Strict boundary checking
|
// Strict boundary checking (IST aware)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (testData.scheduled_date && new Date(testData.scheduled_date) > now) {
|
const scheduledTime = parseAsISTDate(testData.scheduled_date);
|
||||||
setAccessError(`This exam is scheduled for ${new Date(testData.scheduled_date).toLocaleString()}. You cannot start it yet.`);
|
const endTime = parseAsISTDate(testData.end_date);
|
||||||
|
if (scheduledTime && scheduledTime > now) {
|
||||||
|
setAccessError(`This exam is scheduled for ${formatISTDateTime(testData.scheduled_date)}. You cannot start it yet.`);
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (testData.end_date && new Date(testData.end_date) < now) {
|
if (endTime && endTime < now) {
|
||||||
setAccessError(`This exam expired on ${new Date(testData.end_date).toLocaleString()} and is no longer available.`);
|
setAccessError(`This exam expired on ${formatISTDateTime(testData.end_date)} and is no longer available.`);
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useEffect, useState, useMemo } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
||||||
|
import { formatISTDateTime, formatISTTime, parseAsISTDate } from "@/lib/dateUtils"
|
||||||
|
|
||||||
export default function DPPPage() {
|
export default function DPPPage() {
|
||||||
const { user, userId, hasAccessTo } = useActiveUser()
|
const { user, userId, hasAccessTo } = useActiveUser()
|
||||||
@@ -100,24 +101,8 @@ export default function DPPPage() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string) => {
|
const formatDateTime = (dateStr: string) => formatISTDateTime(dateStr);
|
||||||
return new Date(dateStr).toLocaleString("en-US", {
|
const formatTime = (dateStr: string) => formatISTTime(dateStr);
|
||||||
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) => {
|
const getPdfUrl = (title: string) => {
|
||||||
const t = title.toLowerCase();
|
const t = title.toLowerCase();
|
||||||
@@ -190,10 +175,10 @@ export default function DPPPage() {
|
|||||||
const attempt = attemptsByTest[dpp.id];
|
const attempt = attemptsByTest[dpp.id];
|
||||||
const isAttempted = !!attempt;
|
const isAttempted = !!attempt;
|
||||||
|
|
||||||
// Date & Time logic
|
// Date & Time logic (IST aware)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : null;
|
const scheduledTime = parseAsISTDate(dpp.scheduled_date);
|
||||||
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
|
const endTime = parseAsISTDate(dpp.end_date);
|
||||||
const isUpcoming = scheduledTime ? scheduledTime > now : false;
|
const isUpcoming = scheduledTime ? scheduledTime > now : false;
|
||||||
const isExpired = endTime ? now > endTime : false;
|
const isExpired = endTime ? now > endTime : false;
|
||||||
const isLive = !isUpcoming && !isExpired;
|
const isLive = !isUpcoming && !isExpired;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PremiumGate } from "@/components/PremiumGate";
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter";
|
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter";
|
||||||
|
import { formatISTDateTime, formatISTDate, formatISTTime, parseAsISTDate } from "@/lib/dateUtils";
|
||||||
|
|
||||||
interface TestData {
|
interface TestData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -114,32 +115,9 @@ export default function CBTPage() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string) => {
|
const formatDateTime = (dateStr: string) => formatISTDateTime(dateStr);
|
||||||
return new Date(dateStr).toLocaleString("en-US", {
|
const formatDate = (dateStr: string) => formatISTDate(dateStr);
|
||||||
month: 'short',
|
const formatTime = (dateStr: string) => formatISTTime(dateStr);
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
|
||||||
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
|
// Get the highest score for each test if attempted multiple times
|
||||||
const attemptsByTest = attempts.reduce((acc, curr) => {
|
const attemptsByTest = attempts.reduce((acc, curr) => {
|
||||||
@@ -234,8 +212,8 @@ export default function CBTPage() {
|
|||||||
|
|
||||||
// Date logic
|
// Date logic
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const scheduledTime = exam.scheduled_date ? new Date(exam.scheduled_date) : now;
|
const scheduledTime = parseAsISTDate(exam.scheduled_date) || now;
|
||||||
const endTime = exam.end_date ? new Date(exam.end_date) : null;
|
const endTime = parseAsISTDate(exam.end_date);
|
||||||
const isUpcoming = scheduledTime > now;
|
const isUpcoming = scheduledTime > now;
|
||||||
const isExpired = endTime ? now > endTime : false;
|
const isExpired = endTime ? now > endTime : false;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useEffect, useState, useMemo } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useActiveUser } from "@/hooks/useActiveUser"
|
import { useActiveUser } from "@/hooks/useActiveUser"
|
||||||
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
import { CalendarFilter, getLocalDateString } from "@/components/CalendarFilter"
|
||||||
|
import { formatISTDateTime, formatISTTime, parseAsISTDate } from "@/lib/dateUtils"
|
||||||
|
|
||||||
export default function AIDailyPracticePage() {
|
export default function AIDailyPracticePage() {
|
||||||
const { user, userId, hasAccessTo } = useActiveUser()
|
const { user, userId, hasAccessTo } = useActiveUser()
|
||||||
@@ -102,24 +103,8 @@ export default function AIDailyPracticePage() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string) => {
|
const formatDateTime = (dateStr: string) => formatISTDateTime(dateStr);
|
||||||
return new Date(dateStr).toLocaleString("en-US", {
|
const formatTime = (dateStr: string) => formatISTTime(dateStr);
|
||||||
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) => {
|
const getPdfUrl = (title: string) => {
|
||||||
const t = title.toLowerCase();
|
const t = title.toLowerCase();
|
||||||
@@ -188,10 +173,10 @@ export default function AIDailyPracticePage() {
|
|||||||
const attempt = attemptsByTest[dpp.id];
|
const attempt = attemptsByTest[dpp.id];
|
||||||
const isAttempted = !!attempt;
|
const isAttempted = !!attempt;
|
||||||
|
|
||||||
// Accurate Date & Time logic
|
// Accurate Date & Time logic (IST aware)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const scheduledTime = dpp.scheduled_date ? new Date(dpp.scheduled_date) : null;
|
const scheduledTime = parseAsISTDate(dpp.scheduled_date);
|
||||||
const endTime = dpp.end_date ? new Date(dpp.end_date) : null;
|
const endTime = parseAsISTDate(dpp.end_date);
|
||||||
const isUpcoming = scheduledTime ? scheduledTime > now : false;
|
const isUpcoming = scheduledTime ? scheduledTime > now : false;
|
||||||
const isExpired = endTime ? now > endTime : false;
|
const isExpired = endTime ? now > endTime : false;
|
||||||
const isLive = !isUpcoming && !isExpired;
|
const isLive = !isUpcoming && !isExpired;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { BrainCircuit, Play, ChevronRight, Activity, Target, CheckCircle2, Alert
|
|||||||
import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap"
|
import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap"
|
||||||
import { AdaptiveStudyPlan } from "@/components/AI/AdaptiveStudyPlan"
|
import { AdaptiveStudyPlan } from "@/components/AI/AdaptiveStudyPlan"
|
||||||
import { useActiveUser, isDemoUser, DEFAULT_USER_ID } from "@/hooks/useActiveUser"
|
import { useActiveUser, isDemoUser, DEFAULT_USER_ID } from "@/hooks/useActiveUser"
|
||||||
|
import { formatISTDate } from "@/lib/dateUtils"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user, userId, loading: userLoading, hasAccessTo, trialInfo } = useActiveUser()
|
const { user, userId, loading: userLoading, hasAccessTo, trialInfo } = useActiveUser()
|
||||||
@@ -316,7 +317,7 @@ export default function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-primary">{cbt.exam_pattern}</Badge>
|
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-primary">{cbt.exam_pattern}</Badge>
|
||||||
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">{cbt.scheduled_date ? new Date(cbt.scheduled_date).toLocaleDateString() : 'Available Now'}</span>
|
<span className="text-xs text-muted-foreground font-semibold uppercase tracking-wider">{cbt.scheduled_date ? formatISTDate(cbt.scheduled_date) : 'Available Now'}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-lg tracking-tight">{cbt.title}</h3>
|
<h3 className="font-semibold text-lg tracking-tight">{cbt.title}</h3>
|
||||||
<p className="text-sm text-muted-foreground flex items-center gap-1.5 mt-1.5">
|
<p className="text-sm text-muted-foreground flex items-center gap-1.5 mt-1.5">
|
||||||
|
|||||||
@@ -252,7 +252,27 @@ export default function ProgressTrackerPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSubjects(getInitialSubjects(isDemo, user?.target_exam || "JEE_Main"));
|
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) {
|
if (!hasAccessTo("chapter-wise-progress-tracking") && !isDemo) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,15 +13,11 @@ export interface CalendarFilterProps {
|
|||||||
align?: "left" | "right"
|
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 {
|
export function getLocalDateString(dateInput: string | Date | null | undefined): string {
|
||||||
if (!dateInput) return ""
|
return getISTDateString(dateInput)
|
||||||
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({
|
export function CalendarFilter({
|
||||||
|
|||||||
@@ -361,7 +361,23 @@ export function useActiveUser() {
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const handleUpdate = () => fetchUserProfile(activeUserIdRef.current);
|
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-updated", handleUpdate);
|
||||||
|
window.addEventListener("pragativeer-user-switched", handleSwitched);
|
||||||
|
|
||||||
// Poll every 30s (less aggressive, kinder on backend)
|
// Poll every 30s (less aggressive, kinder on backend)
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -370,6 +386,7 @@ export function useActiveUser() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("pragativeer-user-updated", handleUpdate);
|
window.removeEventListener("pragativeer-user-updated", handleUpdate);
|
||||||
|
window.removeEventListener("pragativeer-user-switched", handleSwitched);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
108
src/lib/dateUtils.ts
Normal file
108
src/lib/dateUtils.ts
Normal file
@@ -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`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user