Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/components/FAQSchema.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ interface Props {
}

const { currentPage = 'home', exam } = Astro.props;
const examYear = exam?.sessions?.[0]?.date ? new Date(exam.sessions[0].date).getFullYear() : 2026;

// Generate FAQ data based on context
const generateFAQs = (context: string, exam?: any) => {
if (context === 'exam' && exam) {
return [
{
question: `When is ${exam.name} 2026 exam?`,
answer: `${exam.fullName} 2026 is scheduled for ${new Date(exam.sessions?.[0]?.date || exam.date).toLocaleDateString('en-IN', {
question: `When is ${exam.name} ${examYear} exam?`,
answer: `${exam.fullName} ${examYear} is scheduled for ${new Date(exam.sessions?.[0]?.date || exam.date).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'long',
year: 'numeric'
Expand All @@ -32,7 +33,7 @@ const generateFAQs = (context: string, exam?: any) => {
},
{
question: `Does the countdown timer work offline?`,
answer: `Yes, our countdown timer works offline once loaded. The timer continues to run even without internet connection, ensuring you never lose track of time until ${exam.name} 2026.`
answer: `Yes, our countdown timer works offline once loaded. The timer continues to run even without internet connection, ensuring you never lose track of time until ${exam.name} ${examYear}.`
},
{
question: `What subjects are covered in ${exam.name}?`,
Expand Down Expand Up @@ -120,4 +121,4 @@ const schema = {
</div>
))}
</div>
</div>
</div>
5 changes: 3 additions & 2 deletions src/components/JsonLD.astro
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ if (type === 'website') {
const examUrl = `${baseUrl}/exams/${exam.slug}`;
const currentSession = exam.sessions?.[0];
const examDate = currentSession?.date || exam.date;
const examYear = new Date(examDate).getFullYear();

structuredData = {
"@context": "https://schema.org",
Expand Down Expand Up @@ -122,7 +123,7 @@ if (type === 'website') {
"@type": "WebPage",
"@id": `${examUrl}/#webpage`,
"url": examUrl,
"name": `${exam.name} 2026 Countdown Timer | ${exam.fullName}`,
"name": `${exam.name} ${examYear} Countdown Timer | ${exam.fullName}`,
"description": exam.metaDescription,
"isPartOf": {
"@id": `${baseUrl}/#website`
Expand Down Expand Up @@ -198,4 +199,4 @@ if (type === 'website') {
}
---

<script type="application/ld+json" set:html={JSON.stringify(structuredData)}></script>
<script type="application/ld+json" set:html={JSON.stringify(structuredData)}></script>
24 changes: 14 additions & 10 deletions src/components/SEOHead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ interface Props {

const { exam, currentUrl = Astro.url.href } = Astro.props;
const baseUrl = 'https://timekeeper.edbn.me';
const sessionYear = exam?.sessions?.[0]?.date ? new Date(exam.sessions[0].date).getFullYear() : 2026;

// Helper to calculate time left
function calculateTimeLeft(targetDate: string) {
if (!targetDate) {
function calculateTimeLeft(session?: { date?: string; endDate?: string }) {
if (!session?.date) {
return { expired: true, days: 0, hours: 0, minutes: 0, seconds: 0 };
}

const now = new Date().getTime();
const target = new Date(targetDate).getTime();
const start = new Date(session.date).getTime();
const end = new Date(session.endDate || session.date).getTime();
const target = now < start ? start : end;
const distance = target - now;

if (distance < 0) {
Expand All @@ -31,16 +35,16 @@ function calculateTimeLeft(targetDate: string) {
// Generate dynamic title
function generateDynamicTitle(exam?: any): string {
if (exam) {
const timeLeft = calculateTimeLeft(exam.sessions?.[0]?.date || exam.date);
const timeLeft = calculateTimeLeft(exam.sessions?.[0] || (exam.date ? { date: exam.date } : undefined));
if (timeLeft.expired) {
return `${exam.name} 2026 Exam Date & Results - TimeKeeper`;
return `${exam.name} ${sessionYear} Exam Date & Results - TimeKeeper`;
}

const timeString = timeLeft.days > 0 ?
`${timeLeft.days} Days ${timeLeft.hours}h` :
`${timeLeft.hours}h ${timeLeft.minutes}m`;

return `[${timeString} Left] ${exam.name} Countdown Timer 2026 | Live Exam Date Tracker`;
return `[${timeString} Left] ${exam.name} Countdown Timer ${sessionYear} | Live Exam Date Tracker`;
}

return 'TimeKeeper - Live Countdown Timer for Indian Exams 2026 | JEE, NEET, CAT';
Expand All @@ -49,11 +53,11 @@ function generateDynamicTitle(exam?: any): string {
// Generate dynamic meta description
function generateDynamicDescription(exam?: any): string {
if (exam) {
const timeLeft = calculateTimeLeft(exam.sessions?.[0]?.date || exam.date);
const timeLeft = calculateTimeLeft(exam.sessions?.[0] || (exam.date ? { date: exam.date } : undefined));
const timeString = timeLeft.expired ? 'The exam date has passed. Check for result updates.' :
`Only ${timeLeft.days} days, ${timeLeft.hours} hours left!`;

return `⏰ ${timeString} Track the ${exam.name} 2026 countdown LIVE. Our real-time timer shows you the exact time until the ${exam.fullName}. Features Picture-in-Picture mode, multiple session tracking, and shareable links. Free, no sign-up required.`;
return `⏰ ${timeString} Track the ${exam.name} ${sessionYear} countdown LIVE. Our real-time timer shows you the exact time until the ${exam.fullName}. Features Picture-in-Picture mode, multiple session tracking, and shareable links. Free, no sign-up required.`;
}

return '🏆 Ultimate real-time countdown timer for all major Indian competitive exams 2026. Track JEE Mains, JEE Advanced, NEET, CAT, UPSC, and 50+ other exams. Features live countdowns, PiP mode, and shareable links. Free forever!';
Expand All @@ -77,7 +81,7 @@ function generateDynamicKeywords(exam?: any): string[] {
const examSpecificKeywords = [
`${exam.name} countdown`,
`${exam.name} timer`,
`${exam.name} 2026`,
`${exam.name} ${sessionYear}`,
`${exam.name} exam date`,
`${exam.name} countdown timer`,
`${exam.fullName} countdown`,
Expand Down Expand Up @@ -145,4 +149,4 @@ const dynamicKeywords = generateDynamicKeywords(exam);

<!-- Other Meta -->
<meta name="generator" content={Astro.generator} />
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#ffffff" />
84 changes: 76 additions & 8 deletions src/data/exam-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import metadataData from './exam-metadata.json';
export interface ExamSession {
session: string;
date: string;
endDate?: string;
note?: string;
predicted?: boolean;
}

export interface ExamMetadata {
Expand Down Expand Up @@ -33,6 +36,7 @@ export interface ExamData extends ExamMetadata {
let examDataCache: ExamData[] | null = null;
let metadataLookup: Map<string, ExamMetadata> | null = null;
let sessionsLookup: Map<string, ExamSession[]> | null = null;
const MAX_PREDICTION_YEARS = 5;

/**
* Initialize lookup maps for O(1) access
Expand All @@ -54,6 +58,64 @@ function initializeLookups(): void {
});
}

function getSessionStartTime(session: ExamSession): number {
return new Date(session.date).getTime();
}

function getSessionEndTime(session: ExamSession): number {
return new Date(session.endDate || session.date).getTime();
}

function shiftISODate(date: string, years: number): string {
const [year, month, day] = date.split('-').map(Number);
const shiftedDate = new Date(Date.UTC(year + years, month - 1, day));

// Clamp any overflowed date (for example, February 29 in a non-leap year)
// to the last valid day of the intended month instead of spilling over.
if (shiftedDate.getUTCMonth() !== month - 1) {
shiftedDate.setUTCDate(0);
}

return shiftedDate.toISOString().split('T')[0];
}

function predictFutureSessions(sessions: ExamSession[], now: number): ExamSession[] {
let lastPredictedSessions = sessions;

for (let yearsToAdvance = 1; yearsToAdvance <= MAX_PREDICTION_YEARS; yearsToAdvance++) {
const predictedSessions = sessions.map(session => ({
...session,
date: shiftISODate(session.date, yearsToAdvance),
endDate: session.endDate ? shiftISODate(session.endDate, yearsToAdvance) : undefined,
predicted: true,
note: session.note
? `${session.note} (predicted next cycle from the latest published schedule)`
: 'Predicted next cycle from the latest published schedule'
}));
lastPredictedSessions = predictedSessions;

if (predictedSessions.some(session => getSessionEndTime(session) >= now)) {
return predictedSessions;
}
}

return lastPredictedSessions;
}

function normalizeSessionsForDisplay(sessions: ExamSession[]): ExamSession[] {
if (sessions.length === 0) return [];

const now = Date.now();
const sortedSessions = [...sessions].sort((a, b) => getSessionStartTime(a) - getSessionStartTime(b));
const upcomingOrOngoingSessions = sortedSessions.filter(session => getSessionEndTime(session) >= now);

if (upcomingOrOngoingSessions.length > 0) {
return upcomingOrOngoingSessions;
}

return predictFutureSessions(sortedSessions, now);
}

/**
* Get combined exam data with intelligent caching
*/
Expand All @@ -69,7 +131,7 @@ export function getExamData(): ExamData[] {
examDataCache!.push({
id,
...metadata,
sessions
sessions: normalizeSessionsForDisplay(sessions)
});
});

Expand All @@ -85,7 +147,7 @@ export function getExamById(id: string): ExamData | undefined {
const metadata = metadataLookup!.get(id);
if (!metadata) return undefined;

const sessions = sessionsLookup!.get(id) || [];
const sessions = normalizeSessionsForDisplay(sessionsLookup!.get(id) || []);
return {
id,
...metadata,
Expand Down Expand Up @@ -161,7 +223,7 @@ export function calculateTimeRemaining(exam: ExamData): {
// Calculate time for all sessions
const allSessions = exam.sessions.map(session => ({
session,
timeRemaining: calculateSingleTime(session.date, now)
timeRemaining: calculateSingleTime(session.date, now, session.endDate)
}));

// Find next upcoming session
Expand All @@ -178,8 +240,12 @@ export function calculateTimeRemaining(exam: ExamData): {
/**
* Calculate time for a single date
*/
function calculateSingleTime(targetDate: string, now: number) {
const target = new Date(targetDate).getTime();
function calculateSingleTime(targetDate: string, now: number, endDate?: string) {
const start = new Date(targetDate).getTime();
const end = new Date(endDate || targetDate).getTime();
// Count down to the start date until the window begins, then count down to
// the end of the active exam window so ongoing sessions are not shown as expired.
const target = now < start ? start : end;
const distance = target - now;

if (distance < 0) {
Expand All @@ -199,25 +265,27 @@ function calculateSingleTime(targetDate: string, now: number) {
*/
export function generateExamPageTitle(exam: ExamData): string {
const timeData = calculateTimeRemaining(exam);
const sessionYear = new Date(timeData.nextSession?.date || exam.sessions[0]?.date || Date.now()).getFullYear();

if (timeData.nextSession && !timeData.timeRemaining.expired) {
const { days, hours } = timeData.timeRemaining;
const timeString = days > 0 ? `${days} Days ${hours}h Left` : `${hours}h Left`;
const sessionInfo = exam.sessions.length > 1 ? ` (${timeData.nextSession.session})` : '';
return `${exam.name} Countdown Timer - ${timeString}${sessionInfo} | ${exam.fullName} 2026`;
return `${exam.name} Countdown Timer - ${timeString}${sessionInfo} | ${exam.fullName} ${sessionYear}`;
}

return `${exam.name} 2026 Countdown Timer | ${exam.fullName} Exam Date - TimeKeeper`;
return `${exam.name} ${sessionYear} Countdown Timer | ${exam.fullName} Exam Date - TimeKeeper`;
}

export function generateExamMetaDescription(exam: ExamData): string {
const timeData = calculateTimeRemaining(exam);
const sessionYear = new Date(timeData.nextSession?.date || exam.sessions[0]?.date || Date.now()).getFullYear();

if (timeData.nextSession && !timeData.timeRemaining.expired) {
const { days, hours, minutes } = timeData.timeRemaining;
const timeString = `${days} days, ${hours} hours, ${minutes} minutes remaining`;
const sessionInfo = exam.sessions.length > 1 ? ` for ${timeData.nextSession.session}` : '';
return `${exam.fullName} countdown timer - ${timeString}${sessionInfo}. Track exact time until ${exam.name} 2026 exam. Real-time countdown with precision timing.`;
return `${exam.fullName} countdown timer - ${timeString}${sessionInfo}. Track exact time until ${exam.name} ${sessionYear} exam. Real-time countdown with precision timing.`;
}

return exam.metaDescription;
Expand Down
Loading