diff --git a/app/(main)/admin/page.js b/app/(main)/admin/page.js index 6a85bdb..b3a9e7b 100644 --- a/app/(main)/admin/page.js +++ b/app/(main)/admin/page.js @@ -7,6 +7,7 @@ import { clearHuntTimer, getHuntTimer, formatElapsedTime, + syncTimerWithServer, } from "@/utils/timerService"; export default function AdminDashboard() { @@ -14,7 +15,6 @@ export default function AdminDashboard() { const [timerActive, setTimerActive] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - const [elapsedTime, setElapsedTime] = useState(0); // in seconds const [timerDisplay, setTimerDisplay] = useState("--:--:--"); const [presetHours, setPresetHours] = useState(""); const [presetMinutes, setPresetMinutes] = useState(""); @@ -22,6 +22,7 @@ export default function AdminDashboard() { const [timerEndTime, setTimerEndTime] = useState(null); const timerRef = useRef(null); const timerIntervalRef = useRef(null); + const timerStartTimeRef = useRef(null); // New ref to store the start time const router = useRouter(); // Load timer status on page load @@ -33,8 +34,12 @@ export default function AdminDashboard() { // First, check if we already have timer data in localStorage const existingTimer = getHuntTimer(); if (existingTimer && existingTimer.startTime) { + // Store the start time in the ref to prevent it from being lost on re-renders + timerStartTimeRef.current = existingTimer.startTime; + // If we have timer data, immediately set the display - setTimerActive(existingTimer.isActive); + setTimerActive(existingTimer.isActive !== false); + setTimerDisplay(formatElapsedTime(existingTimer.startTime)); if (existingTimer.endTime) { setTimerEndTime(new Date(existingTimer.endTime).getTime()); } @@ -61,10 +66,15 @@ export default function AdminDashboard() { if (isActive && data.startTime) { setTimerActive(true); + // Store the start time in the ref for persistence across renders + timerStartTimeRef.current = data.startTime; + // Store the start time in the centralized service - setHuntTimer(data.startTime, data.endTime); + setHuntTimer(data.startTime, data.endTime, true); // Explicitly set isActive=true + + // Update the display immediately with the correct time + setTimerDisplay(formatElapsedTime(data.startTime)); - // If there's an end time, set it if (data.endTime) { setTimerEndTime(new Date(data.endTime).getTime()); } else { @@ -72,10 +82,12 @@ export default function AdminDashboard() { } } else { // Only clear local timer if server explicitly says timer is not active - // This prevents clearing a valid timer if the server response is ambiguous if (data.active === false && data.timerActive === false) { clearHuntTimer(); + timerStartTimeRef.current = null; // Clear the start time ref setTimerActive(false); + setTimerDisplay("--:--:--"); + setTimerEndTime(null); } } @@ -111,48 +123,53 @@ export default function AdminDashboard() { } }, [session, status, router]); - // Create a separate effect for updating the timer display + // Create a separate effect for updating the timer display - but only have one instance useEffect(() => { - let timerId; + // Clear any existing interval to avoid duplicates + if (timerIntervalRef.current) { + clearInterval(timerIntervalRef.current); + timerIntervalRef.current = null; + } - // Initialize timer - async function initializeTimer() { - // Set up timer updates - const updateTimer = () => { - const timerData = getHuntTimer(); - if (timerData && timerData.startTime) { - setTimerDisplay(formatElapsedTime(timerData.startTime)); + const updateTimer = () => { + // Use the ref to access the persisted start time + const startTime = timerStartTimeRef.current; - // If there's an end time, check if we've reached it - if (timerEndTime && Date.now() >= timerEndTime) { - stopTimer(); - } - } else { - setTimerDisplay("--:--:--"); + if (timerActive && startTime) { + // Format time using the start time from our ref, which persists across renders + setTimerDisplay(formatElapsedTime(startTime)); + + // Check if timer has ended + if (timerEndTime && Date.now() >= timerEndTime) { + stopTimer(); } - }; - - // Initial update - updateTimer(); - - // Update every second - timerId = setInterval(updateTimer, 1000); - } - - // Only initialize timer if the timer is active - if (timerActive) { - initializeTimer(); - } else { - setTimerDisplay("--:--:--"); - } - - // Clean up interval on unmount - return () => { - if (timerId) { - clearInterval(timerId); + } else if (timerActive) { + // If timer is active but no data, try to restore + syncTimerWithServer().then((result) => { + if (result.success && result.data) { + timerStartTimeRef.current = result.data.startTime; + } + }); + } else { + setTimerDisplay("--:--:--"); } }; - }, [timerActive, timerEndTime]); + + // Initial update + updateTimer(); + + // Set up interval for timer updates + if (timerActive) { + timerIntervalRef.current = setInterval(updateTimer, 1000); + } + + return () => { + if (timerIntervalRef.current) { + clearInterval(timerIntervalRef.current); + timerIntervalRef.current = null; + } + }; + }, [timerActive, timerEndTime]); // Include timerEndTime as a dependency // Add a function to fetch timer status directly from the server const fetchTimerStatusFromServer = async () => { @@ -177,8 +194,8 @@ export default function AdminDashboard() { if (isActive && data.startTime) { setTimerActive(true); + timerStartTimeRef.current = data.startTime; // Update the ref setHuntTimer(data.startTime, data.endTime); - if (data.endTime) { setTimerEndTime(new Date(data.endTime).getTime()); } else { @@ -189,6 +206,7 @@ export default function AdminDashboard() { } else if (data.active === false && data.timerActive === false) { // Only clear timer if server explicitly says timer is inactive setTimerActive(false); + timerStartTimeRef.current = null; // Clear the ref clearHuntTimer(); setTimerDisplay("--:--:--"); } @@ -250,6 +268,9 @@ export default function AdminDashboard() { if (data.success) { setTimerActive(true); + // Store the start time in the ref + timerStartTimeRef.current = data.startTime; + if (endTime) { setTimerEndTime(new Date(endTime).getTime()); } else { @@ -293,6 +314,7 @@ export default function AdminDashboard() { if (data.success) { setTimerActive(false); + timerStartTimeRef.current = null; // Clear the ref setTimerEndTime(null); setTimerDisplay("--:--:--"); @@ -465,6 +487,7 @@ export default function AdminDashboard() { padding: 1rem; display: flex; flex-direction: column; + justify-content: center; align-items: center; padding-bottom: 70px; /* Add space for the navbar */ } diff --git a/app/(main)/clue/[clueId]/page.js b/app/(main)/clue/[clueId]/page.js index 0870169..5d06b08 100644 --- a/app/(main)/clue/[clueId]/page.js +++ b/app/(main)/clue/[clueId]/page.js @@ -47,6 +47,7 @@ export default function CluePage({ params }) { const notificationTimeoutRef = useRef(null); const navErrorTimeoutRef = useRef(null); const timerIdRef = useRef(null); + const timerInitializedRef = useRef(false); // Add this ref to track if timer is already initialized // Check if user is admin const isAdmin = session?.user?.role === "admin"; @@ -194,7 +195,7 @@ export default function CluePage({ params }) { }; }, [router, previousHuntState]); - // Function to ensure we have timer data from the server + // Function to ensure we have timer data from the server - only if needed async function fetchTeamHuntStartTime() { try { // First try to get any existing timer data @@ -204,6 +205,7 @@ export default function CluePage({ params }) { } // If no timer data exists, sync with server + console.log("No timer data found, syncing with server..."); const result = await syncTimerWithServer(); return result.success; } catch (err) { @@ -212,44 +214,53 @@ export default function CluePage({ params }) { } } - // Updated timer effect that follows the same pattern as the map page + // Updated timer effect with protection against multiple initializations useEffect(() => { + // Prevent multiple initializations of the timer + if (timerInitializedRef.current) { + return; + } + // Initialize timer async function initializeTimer() { // First ensure we have timer data await fetchTeamHuntStartTime(); - // Set up timer updates - const updateTimer = () => { - const timerData = getHuntTimer(); - if (timerData && timerData.startTime) { - setElapsedTime(formatElapsedTime(timerData.startTime)); + // Set up timer updates if not already set + if (!timerIdRef.current) { + const updateTimer = () => { + const timerData = getHuntTimer(); + if (timerData && timerData.startTime) { + setElapsedTime(formatElapsedTime(timerData.startTime)); - // Check if timer has ended (if there's an endTime) - if (timerData.endTime) { - const now = Date.now(); - const endTime = new Date(timerData.endTime).getTime(); + // Check if timer has ended (if there's an endTime) + if (timerData.endTime) { + const now = Date.now(); + const endTime = new Date(timerData.endTime).getTime(); - // If current time is past the end time, hunt has ended - if (now >= endTime) { - setHuntActive(false); - clearInterval(timerIdRef.current); + // If current time is past the end time, hunt has ended + if (now >= endTime) { + setHuntActive(false); + clearInterval(timerIdRef.current); + timerIdRef.current = null; - // Redirect to leaderboard with huntEnded flag - console.log( - "Hunt end time reached! Redirecting to leaderboard..." - ); - router.push("/leaderboard?huntEnded=true"); + // Redirect to leaderboard with huntEnded flag + console.log( + "Hunt end time reached! Redirecting to leaderboard..." + ); + router.push("/leaderboard?huntEnded=true"); + } } } - } - }; + }; - // Initial update - updateTimer(); + // Initial update + updateTimer(); - // Update every second - timerIdRef.current = setInterval(updateTimer, 1000); + // Update every second + timerIdRef.current = setInterval(updateTimer, 1000); + timerInitializedRef.current = true; + } } initializeTimer(); @@ -259,9 +270,52 @@ export default function CluePage({ params }) { if (timerIdRef.current) { clearInterval(timerIdRef.current); timerIdRef.current = null; + timerInitializedRef.current = false; } }; - }, [router]); + }, [router]); // Only depend on router + + // Make sure hunt status check doesn't interfere with the timer + useEffect(() => { + // This function should NOT reset the timer or recreate it + async function checkHuntStatus() { + try { + const response = await fetch("/api/hunt-status", { + method: "GET", + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + credentials: "include", + }); + + if (response.ok) { + const data = await response.json(); + + // Only update hunt state, don't touch the timer + const newHuntState = !!data.active; + + // If hunt was active but is now inactive, redirect to leaderboard + if (previousHuntState === true && newHuntState === false) { + console.log("Hunt has ended! Redirecting to leaderboard..."); + router.push("/leaderboard?huntEnded=true"); + return; + } + + // Update the previous hunt state for next comparison + setPreviousHuntState(newHuntState); + } + } catch (err) { + console.error("Failed to check hunt status:", err); + } + } + + const intervalId = setInterval(checkHuntStatus, 5000); + + return () => { + clearInterval(intervalId); + }; + }, [router, previousHuntState]); // Fetch team data on component mount useEffect(() => { diff --git a/app/(main)/map/page.js b/app/(main)/map/page.js index af8e670..6cdaa3c 100644 --- a/app/(main)/map/page.js +++ b/app/(main)/map/page.js @@ -27,6 +27,8 @@ export default function MapPage() { // Add local state for hunt active status const [huntActive, setHuntActive] = useState(null); + // Add previous hunt state to detect when hunt ends + const [previousHuntState, setPreviousHuntState] = useState(null); // Use useCallback for functions called in useEffect to avoid dependency issues const softRedirect = useCallback( @@ -293,6 +295,18 @@ export default function MapPage() { } }, [status, fetchTeamData]); + // Add this useEffect to detect when hunt ends + useEffect(() => { + // If previous state was active but current state is not active, the hunt just ended + if (previousHuntState === true && huntActive === false) { + console.log("Hunt has ended! Redirecting to leaderboard..."); + router.push("/leaderboard?huntEnded=true"); + } + + // Update previous state after checking + setPreviousHuntState(huntActive); + }, [huntActive, previousHuntState, router]); + if (status === "loading" || loading) { return (
@@ -302,8 +316,11 @@ export default function MapPage() { ); } - // If hunt status is known to be inactive, show message - if (huntActive === false) { + // Remove or modify this conditional block since we're now handling hunt ending differently + // Instead of showing an error message, we'll redirect in the useEffect + if (huntActive === false && previousHuntState === null) { + // Only show this if we just landed on the page and hunt is inactive + // (not when the hunt just ended) return (

Hunt Not Active

@@ -371,7 +388,7 @@ export default function MapPage() { {/* Map Banner with team info and timer */}
- {teamData?.team?.name || "Loading..."} + Team Name: {teamData?.team?.name || "Loading..."}
{elapsedTime} diff --git a/app/api/clues/route.js b/app/api/clues/route.js index d2e40af..3937932 100644 --- a/app/api/clues/route.js +++ b/app/api/clues/route.js @@ -1,7 +1,7 @@ import { query, queryOne, run } from "@/lib/db"; import { getToken } from "next-auth/jwt"; -export const dynamic = "force-dynamic"; // Disable caching +export const dynamic = "force-dynamic"; export async function GET(request) { try { @@ -17,7 +17,6 @@ export async function GET(request) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - // Check if user has a team if (!token.teamId) { console.log("API: User without team tried to access clues"); return Response.json( @@ -26,9 +25,7 @@ export async function GET(request) { ); } - // First, ensure the team_clues table has the 'skipped' column try { - // Check if team_clues table exists, create it if it doesn't await run(` CREATE TABLE IF NOT EXISTS team_clues ( team_id INTEGER NOT NULL, @@ -40,7 +37,6 @@ export async function GET(request) { ) `); - // Check if skipped column exists const tableInfo = await query("PRAGMA table_info(team_clues)"); const hasSkippedColumn = tableInfo.some((col) => col.name === "skipped"); @@ -52,18 +48,14 @@ export async function GET(request) { } } catch (schemaError) { console.error("Error ensuring database schema:", schemaError); - // Continue execution - if this fails, the main query might still work - // with existing columns } - // Get all clues with explicit ordering const clues = await query(` SELECT id, title, description, location FROM clues ORDER BY id ASC `); - // For each clue, check if the team has found it const cluesWithStatus = await Promise.all( clues.map(async (clue) => { let clueStatus; @@ -74,7 +66,6 @@ export async function GET(request) { ); } catch (error) { console.error(`Error querying status for clue ${clue.id}:`, error); - // Fallback if query fails (e.g., skipped column still doesn't exist) clueStatus = await queryOne( "SELECT found_at FROM team_clues WHERE team_id = ? AND clue_id = ?", [token.teamId, clue.id] @@ -85,19 +76,17 @@ export async function GET(request) { ...clue, found: !!clueStatus && !(clueStatus.skipped === 1), skipped: !!clueStatus && clueStatus.skipped === 1, - available: false, // Will be determined after processing all clues + available: false, }; }) ); - // Determine which clues should be available let firstUnavailableFound = false; const finalClues = cluesWithStatus.map((clue, index, arr) => { if (clue.found || clue.skipped) { return { ...clue, available: true }; } - // If we haven't found an unavailable clue yet, this one is available if (!firstUnavailableFound) { firstUnavailableFound = true; return { ...clue, available: true }; diff --git a/app/api/teams/create/route.js b/app/api/teams/create/route.js index be7a613..7715b3b 100644 --- a/app/api/teams/create/route.js +++ b/app/api/teams/create/route.js @@ -3,7 +3,6 @@ import { queryOne, run } from "@/lib/db"; export async function POST(request) { try { - // Verify user authentication const token = await getToken({ req: request, secret: @@ -15,20 +14,16 @@ export async function POST(request) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - // Get user ID from token const userId = token.id; - // Get team name from request body const { name } = await request.json(); if (!name || !name.trim()) { return Response.json({ error: "Team name is required" }, { status: 400 }); } - // Generate a unique team code const code = generateTeamCode(); - // Create teams table if it doesn't exist await run(` CREATE TABLE IF NOT EXISTS teams ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -38,7 +33,6 @@ export async function POST(request) { ) `); - // Check if team name already exists const existingTeam = await queryOne("SELECT id FROM teams WHERE name = ?", [ name, ]); @@ -50,7 +44,6 @@ export async function POST(request) { ); } - // Insert team const result = await run("INSERT INTO teams (name, code) VALUES (?, ?)", [ name, code, @@ -61,10 +54,8 @@ export async function POST(request) { throw new Error("Failed to create team - no ID returned"); } - // Update the user's team_id await run("UPDATE users SET team_id = ? WHERE id = ?", [teamId, userId]); - // Return success with team info return Response.json({ success: true, teamId, diff --git a/clue_hunt.db b/clue_hunt.db index de55ce0..725003a 100644 Binary files a/clue_hunt.db and b/clue_hunt.db differ diff --git a/components/CreateTeamForm.js b/components/CreateTeamForm.js index 9d89151..fc7f0dd 100644 --- a/components/CreateTeamForm.js +++ b/components/CreateTeamForm.js @@ -9,7 +9,7 @@ export default function CreateTeamForm({ onSubmit }) { e.preventDefault(); if (!name.trim()) { - return; // Don't submit if name is empty + return; } setSubmitting(true); diff --git a/components/TeamHeader.js b/components/TeamHeader.js index 6a9b0ec..99b5346 100644 --- a/components/TeamHeader.js +++ b/components/TeamHeader.js @@ -1,38 +1,38 @@ -'use client'; -import { useSession } from 'next-auth/react'; -import { useState, useEffect } from 'react'; +"use client"; +import { useSession } from "next-auth/react"; +import { useState, useEffect } from "react"; export default function TeamHeader() { - const { data: session } = useSession(); - const [teamName, setTeamName] = useState(null); + const { data: session } = useSession(); + const [teamName, setTeamName] = useState(null); - useEffect(() => { - // Only fetch team info if user is logged in and not an admin - if (session?.user?.teamId && session?.user?.role !== 'admin') { - fetchTeamInfo(); - } - }, [session]); - - const fetchTeamInfo = async () => { - try { - const response = await fetch(`/api/teams/${session.user.teamId}`); - if (response.ok) { - const data = await response.json(); - setTeamName(data.name); - } - } catch (error) { - console.error('Failed to fetch team info:', error); - } - }; - - // Don't render anything if no team name or user is admin - if (!teamName || session?.user?.role === 'admin') { - return null; + useEffect(() => { + // Only fetch team info if user is logged in and not an admin + if (session?.user?.teamId && session?.user?.role !== "admin") { + fetchTeamInfo(); } + }, [session]); - return ( -
-
{teamName}
-
- ); + const fetchTeamInfo = async () => { + try { + const response = await fetch(`/api/teams/${session.user.teamId}`); + if (response.ok) { + const data = await response.json(); + setTeamName(data.name); + } + } catch (error) { + console.error("Failed to fetch team info:", error); + } + }; + + // Don't render anything if no team name or user is admin + if (!teamName || session?.user?.role === "admin") { + return null; + } + + return ( +
+
Team Name: {teamName}
+
+ ); } diff --git a/lib/auth.js b/lib/auth.js index 17f61ad..2d38cf1 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -1,20 +1,16 @@ import jwt from "jsonwebtoken"; -// Get JWT secret from environment or use fallback const JWT_SECRET = process.env.NEXTAUTH_SECRET || process.env.JWT_SECRET || "your-fallback-secret-should-be-at-least-32-chars"; -// Create a new JWT token export function createToken(payload) { - // Check if payload already has an expiration date set const options = payload.exp ? {} : { expiresIn: "30d" }; return jwt.sign(payload, JWT_SECRET, options); } -// Verify a JWT token export function verifyToken(token) { try { if (!token) { @@ -26,13 +22,11 @@ export function verifyToken(token) { const decoded = jwt.verify(token, JWT_SECRET); - // Validate that we have the minimum required fields if (!decoded.sub && !decoded.id) { console.error("Token missing required user identifier"); return null; } - // Log successful token verification with user ID console.log( "Token verified successfully for user:", decoded.sub || decoded.id @@ -40,7 +34,6 @@ export function verifyToken(token) { return decoded; } catch (error) { - // Provide detailed error logs if (error.name === "TokenExpiredError") { console.error("Token expired at:", error.expiredAt); } else if (error.name === "JsonWebTokenError") { diff --git a/utils/timerService.js b/utils/timerService.js index 64b2eb9..b53a3b9 100644 --- a/utils/timerService.js +++ b/utils/timerService.js @@ -154,18 +154,41 @@ export const syncTimerWithServer = async () => { } const data = await response.json(); + console.log("Timer sync: Server returned data:", data); + + // Don't change timer if we already have it and server confirms it's active + const existingTimer = getHuntTimer(); + if (existingTimer && existingTimer.startTime && data.active) { + console.log("Timer sync: Using existing timer data, confirmed by server"); + return { + success: true, + data: { + startTime: existingTimer.startTime, + endTime: existingTimer.endTime || data.endTime, + active: true, + }, + }; + } if (data.active && data.startTime) { - setHuntTimer(data.startTime, data.endTime); + console.log( + "Timer sync: Setting timer with startTime:", + new Date(data.startTime).toISOString() + ); + + // Ensure isActive is explicitly true + setHuntTimer(data.startTime, data.endTime, true); + return { success: true, data: { startTime: data.startTime, endTime: data.endTime, - active: data.active, + active: true, }, }; } else { + console.log("Timer sync: Clearing timer as server reports inactive"); clearHuntTimer(); return { success: true, data: null }; }