fix: resolve IST/UTC timezone scheduling delta and enable syllabus tracker hot-reload on JEE+NEET switch

This commit is contained in:
2026-10-23 21:22:49 +05:30
parent 4ae7c8fb65
commit 9cb3e550d4
12 changed files with 198 additions and 101 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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">

View File

@@ -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 (

View File

@@ -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({

View File

@@ -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
View 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`;
}