fixed the map, made it so the map is scrollable and synced the timing between the admin page and map page. They now share the same elapsed time. need to fix map css for altering the svg.

Grey = cant click
Red = current
Green = complete
blue = skipped?

Need to add Clue page Need to fix Leaderboard.
This commit is contained in:
bobbert committed 2025-03-15 16:30:04 +00:00
1 parent e7ad53643c
commit 16319f1333
14 files changed
+791 -472

No files matched your search

-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/fill-database.sql" dialect="SQLite" />
<file url="file://$PROJECT_DIR$/init-schema.sql" dialect="SQLite" />
</component>
</project>
+16 -1
View File
@@ -2,6 +2,11 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import {
setHuntTimer,
clearHuntTimer,
formatElapsedTime,
} from "@/utils/timerService";
export default function AdminDashboard() { export default function AdminDashboard() {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
@@ -38,6 +43,9 @@ export default function AdminDashboard() {
const currentTime = Date.now(); const currentTime = Date.now();
const elapsed = Math.floor((currentTime - startTime) / 1000); const elapsed = Math.floor((currentTime - startTime) / 1000);
setElapsedTime(elapsed); setElapsedTime(elapsed);
// Update global timer data for all clients
setHuntTimer(data.startTime, data.endTime);
} }
// If there's an end time, set it // If there's an end time, set it
@@ -182,6 +190,9 @@ export default function AdminDashboard() {
setTimerEndTime(null); setTimerEndTime(null);
} }
// Update global timer data for all clients
setHuntTimer(data.startTime, data.endTime);
setError(""); setError("");
} else { } else {
setError(data.error || "Failed to start timer"); setError(data.error || "Failed to start timer");
@@ -210,7 +221,11 @@ export default function AdminDashboard() {
if (data.success) { if (data.success) {
setTimerActive(false); setTimerActive(false);
setTimerEndTime(null); setTimerEndTime(null);
setError(""); setElapsedTime(0);
formatTimeDisplay(0);
// Clear timer data for all clients
clearHuntTimer();
} else { } else {
setError(data.error || "Failed to stop timer"); setError(data.error || "Failed to stop timer");
} }
+3
View File
@@ -0,0 +1,3 @@
export default function ClueLayout({ children }) {
return <div className="clue-layout">{children}</div>;
}
+149 -134
View File
@@ -1,96 +1,90 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useHuntStatusCheck } from "@/hooks/useHuntStatusCheck"; import { useSession } from "next-auth/react";
import Link from "next/link"; import { formatElapsedTime, getHuntTimer } from "@/utils/timerService";
import { use } from "react"; // Add this import for React.use()
export default function CluePage({ params }) { export default function CluePage({ params }) {
const { data: session, status } = useSession();
// Unwrap the params Promise using React.use()
const unwrappedParams = use(params);
const { clueId } = unwrappedParams;
const [clue, setClue] = useState(null); const [clue, setClue] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [foundNow, setFoundNow] = useState(false);
const [elapsedTime, setElapsedTime] = useState("00:00:00");
const router = useRouter(); const router = useRouter();
const { status } = useSession();
// Add this line to use the hunt status hook const { clueId } = params;
const huntActive = useHuntStatusCheck();
useEffect(() => { useEffect(() => {
async function fetchClueDetails() { if (status === "unauthenticated") {
router.push("/login");
return;
}
if (status === "loading") {
return;
}
// Fetch clue data
async function fetchClue() {
try { try {
// Try to check hunt status first with robust error handling setLoading(true);
try {
const statusResponse = await fetch("/api/hunt-status", {
credentials: "include",
headers: { Accept: "application/json" },
});
// Read as text first
const statusText = await statusResponse.text();
try {
const statusData = JSON.parse(statusText);
if (!statusData.active && statusData.authenticated) {
router.push("/");
return;
}
} catch (parseError) {
console.error("Failed to parse hunt status response:", parseError);
// Continue anyway to try fetching clue details
}
} catch (statusErr) {
console.error("Error checking hunt status:", statusErr);
// Continue anyway to try fetching clue details
}
// Now fetch clue details with robust error handling
const response = await fetch(`/api/clues/${clueId}`, { const response = await fetch(`/api/clues/${clueId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
credentials: "include", credentials: "include",
headers: { Accept: "application/json" },
}); });
// Read as text first if (!response.ok) {
const responseText = await response.text(); if (response.status === 404) {
throw new Error("Clue not found");
// Try to parse as JSON } else {
let data; const data = await response.json();
try { throw new Error(data.error || "Failed to fetch clue");
data = JSON.parse(responseText); }
} catch (parseError) {
console.error("Failed to parse clue details response:", parseError);
console.log("Response received:", responseText.slice(0, 200));
throw new Error("Invalid response from server. Please try again.");
} }
// Check for error in the response const data = await response.json();
if (data.error) { setClue(data);
throw new Error(data.error); setFoundNow(data.foundNow);
} setLoading(false);
setClue(data.clue);
} catch (err) { } catch (err) {
console.error("Error fetching clue details:", err); console.error("Error fetching clue:", err);
setError(err.message); setError(err.message);
} finally {
setLoading(false); setLoading(false);
} }
} }
if (clueId) { fetchClue();
fetchClueDetails(); }, [clueId, router, status]);
// Update timer display
useEffect(() => {
// Set up timer display updates
const timerData = getHuntTimer();
if (!timerData || !timerData.isActive) {
return;
} }
}, [clueId, router]);
const timer = setInterval(() => {
setElapsedTime(formatElapsedTime(timerData.startTime));
}, 1000);
// Initial set
setElapsedTime(formatElapsedTime(timerData.startTime));
return () => clearInterval(timer);
}, []);
if (loading) { if (loading) {
return ( return (
<div className="clue-container"> <div className="clue-container">
<div className="loading">Loading clue details...</div> <div className="loading">
<div className="loading-spinner"></div>
<p>Loading clue...</p>
</div>
</div> </div>
); );
} }
@@ -130,6 +124,15 @@ export default function CluePage({ params }) {
return ( return (
<div className="clue-container"> <div className="clue-container">
<div className="clue-scroll"> <div className="clue-scroll">
{foundNow && (
<div className="new-discovery">
<div className="discovery-icon">🎉</div>
<div className="discovery-text">New Clue Discovered!</div>
</div>
)}
<div className="timer-display">{elapsedTime}</div>
<h1 className="clue-title">{clue.title}</h1> <h1 className="clue-title">{clue.title}</h1>
<div className="clue-content"> <div className="clue-content">
@@ -165,59 +168,13 @@ export default function CluePage({ params }) {
} }
.clue-scroll { .clue-scroll {
background-color: #f5e6ca; background-color: #f8f3e3;
border: 2px solid #9c6b30;
border-radius: 10px; border-radius: 10px;
padding: 30px; padding: 30px;
max-width: 800px;
width: 100%; width: 100%;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); max-width: 600px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
}
.clue-scroll::before,
.clue-scroll::after {
content: "";
position: absolute;
height: 30px;
width: 100%;
left: 0;
}
.clue-scroll::before {
top: 0;
background: linear-gradient(
180deg,
rgba(156, 107, 48, 0.2) 0%,
rgba(156, 107, 48, 0) 100%
);
border-radius: 8px 8px 0 0;
}
.clue-scroll::after {
bottom: 0;
background: linear-gradient(
0deg,
rgba(156, 107, 48, 0.2) 0%,
rgba(156, 107, 48, 0) 100%
);
border-radius: 0 0 8px 8px;
}
.clue-title {
color: #5d4037;
font-size: 2rem;
text-align: center;
margin-bottom: 20px;
font-family: "Pirates", cursive, serif;
border-bottom: 2px dashed #9c6b30;
padding-bottom: 15px;
}
.clue-content {
padding: 20px;
background-color: #f9f3e3;
border-radius: 8px;
margin-bottom: 20px; margin-bottom: 20px;
border: 1px dashed #9c6b30; border: 1px dashed #9c6b30;
} }
@@ -253,51 +210,109 @@ export default function CluePage({ params }) {
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
border-radius: 5px; border-radius: 5px;
font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: background-color 0.3s, transform 0.2s; font-weight: bold;
transition: all 0.2s;
} }
.map-button { .map-button {
background-color: #9c6b30; background-color: #68543c;
color: white; color: white;
} }
.home-button { .home-button {
background-color: #5d4037; background-color: #a98467;
color: white; color: white;
} }
.map-button:hover, .map-button:hover,
.home-button:hover { .home-button:hover {
transform: translateY(-3px); transform: scale(1.05);
}
.map-button:hover {
background-color: #7d562a;
}
.home-button:hover {
background-color: #4a332e;
} }
.loading, .loading,
.error, .error,
.not-found { .not-found {
padding: 50px;
text-align: center; text-align: center;
border: 2px dashed #9c6b30; padding: 30px;
border-radius: 8px; }
margin: 30px 0;
color: #5d4037; .loading-spinner {
background: #f5e6ca; border: 4px solid #f3f3f3;
font-size: 1.2rem; border-top: 4px solid #9c6b30;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
.error { .error {
border-color: #d32f2f;
color: #d32f2f; color: #d32f2f;
} }
.clue-title {
color: #68543c;
font-size: 2rem;
text-align: center;
margin-bottom: 20px;
border-bottom: 2px solid #9c6b30;
padding-bottom: 10px;
}
.timer-display {
position: absolute;
top: 15px;
right: 15px;
background-color: rgba(0, 0, 0, 0.1);
padding: 5px 10px;
border-radius: 5px;
font-family: monospace;
font-weight: bold;
font-size: 1rem;
}
.new-discovery {
background-color: #feefb3;
border: 1px solid #9f6000;
color: #9f6000;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
display: flex;
align-items: center;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
.discovery-icon {
font-size: 1.5rem;
margin-right: 10px;
}
.discovery-text {
font-weight: bold;
}
`}</style> `}</style>
</div> </div>
); );
+42 -13
View File
@@ -5,7 +5,7 @@
height: 100vh; /* Full viewport height */ height: 100vh; /* Full viewport height */
overflow: hidden; /* Hide overflow at page level */ overflow: hidden; /* Hide overflow at page level */
position: relative; position: relative;
background-color: #69B5A8; background-color: #69b5a8;
} }
.map-banner { .map-banner {
@@ -59,40 +59,69 @@
/* Map container takes all available space */ /* Map container takes all available space */
.map-container { .map-container {
flex: 1; /* Take all available space */ flex: 1; /* Take all available space */
overflow: auto; /* Enable scrolling */
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: calc(100vh - 120px); /* Adjust for header and navbar height */ background-color: #69b5a8; /* Background color */
background-color: #69B5A8; /* Updated background color */ overflow: auto; /* Enable scrolling */
} }
.treasure-map { .treasure-map {
position: relative; /* Changed from absolute to relative */
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 100%;
min-height: 100%;
}
/* SVG container for better scrolling and zooming */
.svg-container {
position: relative; position: relative;
width: 100%;
height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
overflow: auto;
} }
/* New class for the background map image */ .svg-container svg {
.map-background { min-width: 100%;
width: 100%;
height: 100%;
min-height: 100%; min-height: 100%;
width: auto;
height: auto;
display: block;
transform-origin: center center;
}
/* Map background - display as full-page map */
.map-background {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; width: 100%;
bottom: 0; height: 100%;
background-image: url("/images/map.svg"); /* Updated path to the correct SVG file */ min-width: 100%;
background-size: contain; min-height: 100%;
background-image: url("/images/map.svg");
background-size: contain; /* Change back to contain for proper aspect ratio */
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
z-index: 5; /* Ensure the map is above the background */ z-index: 5;
} }
/* Circle elements in the SVG map - attach to a container class */
.svg-container circle {
cursor: pointer;
transition: fill 0.3s ease, transform 0.3s ease;
}
.svg-container circle:hover {
fill: #ffb74d !important;
transform: scale(1.1);
}
/* Remaining styles unchanged */
.map-svg { .map-svg {
width: 100% !important; /* Override Next.js Image default width */ width: 100% !important; /* Override Next.js Image default width */
height: auto !important; /* Override Next.js Image default height */ height: auto !important; /* Override Next.js Image default height */
+329 -138
View File
@@ -1,9 +1,15 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import styles from "./map.module.css"; import styles from "./map.module.css";
import {
getHuntTimer,
setHuntTimer,
clearHuntTimer,
formatElapsedTime,
} from "@/utils/timerService";
export default function MapPage() { export default function MapPage() {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
@@ -12,14 +18,34 @@ export default function MapPage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [teamData, setTeamData] = useState(null); const [teamData, setTeamData] = useState(null);
const [elapsedTime, setElapsedTime] = useState("00:00:00"); const [elapsedTime, setElapsedTime] = useState("00:00:00");
const [mapSvg, setMapSvg] = useState(null);
const [clues, setClues] = useState([]);
const mapContainerRef = useRef(null); const mapContainerRef = useRef(null);
const router = useRouter(); const router = useRouter();
const isMounted = useRef(true);
// Add local state for hunt active status // Add local state for hunt active status
const [huntActive, setHuntActive] = useState(null); const [huntActive, setHuntActive] = useState(null);
// Use useCallback for functions called in useEffect to avoid dependency issues
const softRedirect = useCallback(
(url) => {
if (isMounted.current) {
router.push(url);
}
},
[router]
);
// Hard redirect function (for when a full page reload is needed)
const hardRedirect = useCallback((url) => {
if (typeof window !== "undefined" && isMounted.current) {
window.location.href = url;
}
}, []);
// Add function to fetch team hunt start time // Add function to fetch team hunt start time
const fetchTeamHuntStartTime = async () => { const fetchTeamHuntStartTime = useCallback(async () => {
try { try {
// First check localStorage for cached start time // First check localStorage for cached start time
const cachedStartTime = localStorage.getItem("teamHuntStartTime"); const cachedStartTime = localStorage.getItem("teamHuntStartTime");
@@ -59,124 +85,12 @@ export default function MapPage() {
console.error("Failed to fetch team hunt start time:", error); console.error("Failed to fetch team hunt start time:", error);
return new Date().toISOString(); return new Date().toISOString();
} }
}; }, [session?.user?.teamId]);
// Initial hunt status check // IMPORTANT: Move fetchTeamData up here, before it's referenced in useEffect dependencies
useEffect(() => { const fetchTeamData = useCallback(async () => {
// Check if hunt is active if (!isMounted.current) return;
checkHuntAndTimer();
// Set up periodic checks
const interval = setInterval(checkHuntAndTimer, 3000);
return () => clearInterval(interval);
}, []);
// Handle authentication and hunt status
useEffect(() => {
// Only redirect if explicitly not authenticated
if (status === "unauthenticated") {
router.push("/login");
return;
}
// If hunt is not active, redirect to waiting page
if (huntActive === false) {
console.log("Hunt is not active, redirecting to waiting page");
router.push("/waiting");
return;
}
// Only fetch team data if authenticated and hunt is active or status unknown
if (status === "authenticated" && huntActive !== false) {
fetchTeamData();
}
}, [status, huntActive, router]);
// Function to check hunt status and timer
async function checkHuntAndTimer() {
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();
console.log("Hunt status check from map:", data);
// If hunt is not active, check if it has ended
if (!data.active) {
console.log("Hunt not active, checking if it ended...");
// Check if we had an active hunt before (using localStorage)
const wasActive = localStorage.getItem("teamHuntStartTime");
if (wasActive) {
// Hunt was active before but not now - it ended
console.log("Hunt has ended, redirecting to leaderboard");
localStorage.removeItem("teamHuntStartTime"); // Clean up
window.location.href = "/leaderboard?huntEnded=true";
return;
}
// Otherwise just show the waiting page
setHuntActive(false);
router.push("/waiting");
return;
}
// If timer endTime exists and has passed, redirect to leaderboard
if (data.endTime && new Date(data.endTime) <= new Date()) {
console.log("Timer expired, redirecting to leaderboard");
// Clean up the storage
localStorage.removeItem("teamHuntStartTime");
router.push("/leaderboard?huntEnded=true");
return;
}
// Otherwise, hunt is active and timer is running
setHuntActive(true);
}
} catch (err) {
console.error("Failed to check hunt status:", err);
}
}
// Use team hunt start time for elapsed time calculation
useEffect(() => {
if (!teamData?.team) return;
let startTimePromise = fetchTeamHuntStartTime();
startTimePromise.then((startTimeStr) => {
// Parse the ISO string to milliseconds
const startTime = new Date(startTimeStr).getTime();
const timer = setInterval(() => {
const now = Date.now();
const elapsed = Math.floor((now - startTime) / 1000);
const hours = Math.floor(elapsed / 3600)
.toString()
.padStart(2, "0");
const minutes = Math.floor((elapsed % 3600) / 60)
.toString()
.padStart(2, "0");
const seconds = Math.floor(elapsed % 60)
.toString()
.padStart(2, "0");
setElapsedTime(`${hours}:${minutes}:${seconds}`);
}, 1000);
return () => clearInterval(timer);
});
}, [teamData, session]);
const fetchTeamData = async () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -207,7 +121,11 @@ export default function MapPage() {
setError("You need to join a team before viewing the map"); setError("You need to join a team before viewing the map");
setLoading(false); setLoading(false);
// Give user time to read the error message before redirect // Give user time to read the error message before redirect
setTimeout(() => router.push("/team-selection"), 2000); setTimeout(() => {
if (isMounted.current) {
softRedirect("/team-selection");
}
}, 2000);
return; return;
} else { } else {
throw new Error(errorData.error || "Failed to fetch team data"); throw new Error(errorData.error || "Failed to fetch team data");
@@ -215,18 +133,279 @@ export default function MapPage() {
} }
const data = await response.json(); const data = await response.json();
setTeamData(data); if (isMounted.current) {
setLoading(false); setTeamData(data);
setLoading(false);
}
} catch (err) { } catch (err) {
console.error("Error fetching team data:", err); console.error("Error fetching team data:", err);
setError(err.message); if (isMounted.current) {
setLoading(false); setError(err.message);
setLoading(false);
}
} }
}; }, [softRedirect]);
// Function to fetch clues
const fetchClues = useCallback(async () => {
try {
const response = await fetch("/api/clues", {
headers: { "Cache-Control": "no-cache, no-store, must-revalidate" },
credentials: "include",
});
if (response.ok) {
const cluesData = await response.json();
setClues(cluesData);
}
} catch (error) {
console.error("Failed to fetch clues:", error);
}
}, []);
// Function to load and enhance the SVG map
const loadAndEnhanceMap = useCallback(async () => {
try {
const response = await fetch("/images/map.svg");
if (!response.ok) {
setMapError(true);
return;
}
const svgText = await response.text();
setMapSvg(svgText);
} catch (error) {
console.error("Failed to load map SVG:", error);
setMapError(true);
}
}, []);
// Function to enhance SVG with clickable areas
const enhanceSvgMap = useCallback(() => {
if (!mapContainerRef.current || !clues.length) return;
// Find all circles in the SVG
const circleSvgElements =
mapContainerRef.current.querySelectorAll("circle");
// Convert NodeList to Array for easier manipulation
Array.from(circleSvgElements).forEach((circle, index) => {
// Only process if we have a clue for this circle
if (clues[index]) {
// Make circle interactive
circle.style.cursor = "pointer";
circle.style.fill = "#ff9800";
circle.style.stroke = "#d87f00";
circle.style.strokeWidth = "2";
// Add click handler to navigate to the clue page
circle.onclick = () => {
router.push(`/clue/${clues[index].id}`);
};
// Add hover effects
circle.onmouseenter = () => {
circle.style.fill = "#ffb74d";
circle.style.transform = "scale(1.1)";
circle.style.transition = "all 0.3s ease";
};
circle.onmouseleave = () => {
circle.style.fill = "#ff9800";
circle.style.transform = "scale(1)";
};
// Optional: Add label or tooltip
const label = document.createElementNS(
"http://www.w3.org/2000/svg",
"title"
);
label.textContent = `Clue #${index + 1}: ${
clues[index].title || "Mystery clue"
}`;
circle.appendChild(label);
}
});
}, [clues, router]);
// Clean up function for unmounting
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
// Initial hunt status check
useEffect(() => {
// Check if hunt is active
checkHuntAndTimer();
// Set up periodic checks
const interval = setInterval(checkHuntAndTimer, 3000);
return () => clearInterval(interval);
}, []);
// Handle authentication and hunt status
useEffect(() => {
// Only redirect if explicitly not authenticated
if (status === "unauthenticated") {
softRedirect("/login");
return;
}
// If hunt is not active, redirect to waiting page
if (huntActive === false) {
console.log("Hunt is not active, redirecting to waiting page");
softRedirect("/waiting");
return;
}
// Only fetch team data if authenticated and hunt is active or status unknown
if (status === "authenticated" && huntActive !== false) {
fetchTeamData();
loadAndEnhanceMap();
fetchClues();
}
}, [
status,
huntActive,
softRedirect,
fetchTeamData,
loadAndEnhanceMap,
fetchClues,
]);
// Add effect to enhance SVG once it's loaded and clues are fetched
useEffect(() => {
if (mapSvg && clues.length > 0) {
// Let the DOM update first with the SVG content
setTimeout(() => {
enhanceSvgMap();
}, 100);
}
}, [mapSvg, clues, enhanceSvgMap]);
// Function to check hunt status and timer
async function checkHuntAndTimer() {
if (!isMounted.current) return;
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();
console.log("Hunt status check from map:", data);
// If hunt is not active, check if it has ended
if (!data.active) {
console.log("Hunt not active, checking if it ended...");
// Check if we had an active hunt before
const timerData = getHuntTimer();
if (timerData && timerData.isActive) {
// Hunt was active before but not now - it has ended
console.log("Hunt has ended, redirecting to leaderboard");
clearHuntTimer(); // Clean up all timer data
// Use the appropriate redirect method
if (isMounted.current) {
hardRedirect("/leaderboard?huntEnded=true");
}
return;
}
// Otherwise just show the waiting page
if (isMounted.current) {
setHuntActive(false);
softRedirect("/waiting");
}
return;
}
// If timer endTime exists and has passed, redirect to leaderboard
if (data.endTime && new Date(data.endTime) <= new Date()) {
console.log("Timer expired, redirecting to leaderboard");
clearHuntTimer(); // Clean up all timer data
if (isMounted.current) {
softRedirect("/leaderboard?huntEnded=true");
}
return;
}
// Otherwise, hunt is active and timer is running
if (isMounted.current) {
setHuntActive(true);
// Update hunt timer data with the server's values
if (data.startTime) {
setHuntTimer(data.startTime, data.endTime);
}
}
}
} catch (err) {
console.error("Failed to check hunt status:", err);
}
}
// Use team hunt start time for elapsed time calculation
useEffect(() => {
if (!teamData?.team) return;
// Get hunt timer data from the centralized service
let timerData = getHuntTimer();
// If no timer data yet, fetch it
if (!timerData) {
fetchTeamHuntStartTime().then((startTimeStr) => {
setHuntTimer(startTimeStr);
timerData = getHuntTimer();
startTimer(timerData);
});
} else {
startTimer(timerData);
}
function startTimer(timerData) {
const timer = setInterval(() => {
if (!isMounted.current) {
clearInterval(timer);
return;
}
// Refresh timer data on each interval to catch any updates
const currentTimerData = getHuntTimer();
if (!currentTimerData || !currentTimerData.isActive) {
setElapsedTime("00:00:00");
return;
}
setElapsedTime(formatElapsedTime(currentTimerData.startTime));
}, 1000);
return () => clearInterval(timer);
}
// Cleanup function
return () => {
isMounted.current = false;
};
}, [teamData, fetchTeamHuntStartTime]);
// Check hunt status every second to ensure immediate updates // Check hunt status every second to ensure immediate updates
useEffect(() => { useEffect(() => {
const checkHuntStatus = async () => { const checkHuntStatus = async () => {
if (!isMounted.current) return;
try { try {
const timestamp = Date.now(); // Add cache-busting timestamp const timestamp = Date.now(); // Add cache-busting timestamp
const response = await fetch(`/api/hunt-status?t=${timestamp}`, { const response = await fetch(`/api/hunt-status?t=${timestamp}`, {
@@ -250,19 +429,25 @@ export default function MapPage() {
// If hunt is not active, force redirect to waiting page // If hunt is not active, force redirect to waiting page
if (!data.active) { if (!data.active) {
console.log("Hunt not active, redirecting to waiting page"); console.log("Hunt not active, redirecting to waiting page");
window.location.href = "/waiting"; if (isMounted.current) {
softRedirect("/waiting");
}
return; return;
} }
// If timer endTime exists and has passed, redirect to leaderboard // If timer endTime exists and has passed, redirect to leaderboard
if (data.endTime && new Date(data.endTime) <= new Date()) { if (data.endTime && new Date(data.endTime) <= new Date()) {
console.log("Timer expired, redirecting to leaderboard"); console.log("Timer expired, redirecting to leaderboard");
window.location.href = "/leaderboard?huntEnded=true"; if (isMounted.current) {
softRedirect("/leaderboard?huntEnded=true");
}
return; return;
} }
// Otherwise, hunt is active and timer is running // Otherwise, hunt is active and timer is running
setHuntActive(true); if (isMounted.current) {
setHuntActive(true);
}
} catch (err) { } catch (err) {
console.error("Failed to check hunt status:", err); console.error("Failed to check hunt status:", err);
} }
@@ -272,7 +457,7 @@ export default function MapPage() {
checkHuntStatus(); checkHuntStatus();
const intervalId = setInterval(checkHuntStatus, 1000); const intervalId = setInterval(checkHuntStatus, 1000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, []); }, [softRedirect]);
if (status === "loading" || loading) { if (status === "loading" || loading) {
return ( return (
@@ -289,7 +474,7 @@ export default function MapPage() {
<div className={styles["map-error"]}> <div className={styles["map-error"]}>
<h2>Hunt Not Active</h2> <h2>Hunt Not Active</h2>
<p>The treasure hunt has not started yet.</p> <p>The treasure hunt has not started yet.</p>
<button onClick={() => router.push("/waiting")}> <button onClick={() => softRedirect("/waiting")}>
Go to Waiting Room Go to Waiting Room
</button> </button>
</div> </div>
@@ -311,7 +496,7 @@ export default function MapPage() {
<div className={styles["map-error"]}> <div className={styles["map-error"]}>
<h2>Error Loading Map</h2> <h2>Error Loading Map</h2>
<p>We couldn't load the treasure map. Please try again.</p> <p>We couldn't load the treasure map. Please try again.</p>
<button onClick={fetchTeamData}>Retry</button> <button onClick={loadAndEnhanceMap}>Retry</button>
</div> </div>
); );
} }
@@ -329,19 +514,25 @@ export default function MapPage() {
<div className={styles["team-code"]}>{teamData?.team?.code || ""}</div> <div className={styles["team-code"]}>{teamData?.team?.code || ""}</div>
</div> </div>
{/* Main map content area - showing map as background */} {/* Main map content area - map fills the entire container */}
<div className={styles["map-container"]} ref={mapContainerRef}> <div className={styles["map-container"]} ref={mapContainerRef}>
<div className={styles["treasure-map"]}> <div className={styles["treasure-map"]}>
{/* Using background-image for the map SVG */} {mapSvg ? (
<div <div
className={styles["map-background"]} className={styles["svg-container"]}
role="img" dangerouslySetInnerHTML={{ __html: mapSvg }}
aria-label="Treasure Map" />
></div> ) : (
<div
className={styles["map-background"]}
role="img"
aria-label="Treasure Map"
></div>
)}
</div> </div>
</div> </div>
{/* Navigation bar - Map as first button */} {/* Navigation bar at the bottom */}
<nav className={styles["nav-bar"]}> <nav className={styles["nav-bar"]}>
<Link <Link
href="/map" href="/map"
+79 -52
View File
@@ -1,70 +1,97 @@
import { queryOne, run } from "@/lib/db"; import { queryOne } from "@/lib/db";
import { cookies } from "next/headers"; import { getToken } from "next-auth/jwt";
import { verifyToken } from "@/app/api/auth/[...nextauth]/route";
export const dynamic = "force-dynamic"; // Disable caching export const dynamic = "force-dynamic"; // Disable caching
// GET endpoint to retrieve a specific clue export async function GET(request, context) {
export async function GET(request, { params }) {
const { clueId } = params;
try { try {
// Check if the hunt is active first const token = await getToken({
const huntActiveSetting = await queryOne( req: request,
"SELECT value FROM settings WHERE key = 'hunt_active'" secret:
); process.env.NEXTAUTH_SECRET ||
const huntActive = huntActiveSetting && huntActiveSetting.value === "true"; "your-fallback-secret-should-be-at-least-32-chars",
});
// Only verify authentication when hunt is not active if (!token) {
if (!huntActive) { return Response.json({ error: "Unauthorized" }, { status: 401 });
const cookieStore = await cookies();
const sessionToken =
cookieStore.get("next-auth.session-token")?.value ||
cookieStore.get("__Secure-next-auth.session-token")?.value;
if (!sessionToken) {
return Response.json(
{ error: "Unauthorized", clue: null },
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
const userData = verifyToken(sessionToken);
if (!userData) {
return Response.json(
{ error: "Invalid session", clue: null },
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
// Admin can see clues even if hunt is not active
if (userData.role !== "admin") {
return Response.json(
{ error: "Hunt is not active", clue: null },
{ headers: { "Content-Type": "application/json" } }
);
}
} }
// Fetch the specific clue // Check if user has a team
const clue = await queryOne("SELECT * FROM clues WHERE id = ?", [clueId]); if (!token.teamId) {
if (!clue) {
return Response.json( return Response.json(
{ error: "Clue not found", clue: null }, { error: "You must be part of a team to view clues" },
{ status: 404, headers: { "Content-Type": "application/json" } } { status: 403 }
); );
} }
const { clueId } = context.params;
// Validate clueId is a number
if (isNaN(parseInt(clueId))) {
return Response.json({ error: "Invalid clue ID" }, { status: 400 });
}
// Get the specific clue
const clue = await queryOne(
"SELECT id, title, description, location, x_coord, y_coord FROM clues WHERE id = ?",
[clueId]
);
if (!clue) {
return Response.json({ error: "Clue not found" }, { status: 404 });
}
// Check if this team has found this clue before
const found = await queryOne(
"SELECT * FROM team_clues WHERE team_id = ? AND clue_id = ?",
[token.teamId, clueId]
);
// If not found yet, mark it as found now
if (!found) {
try {
// First ensure the team_clues table exists
await queryOne(`
CREATE TABLE IF NOT EXISTS team_clues (
team_id INTEGER NOT NULL,
clue_id INTEGER NOT NULL,
found_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (team_id, clue_id),
FOREIGN KEY (team_id) REFERENCES teams(id),
FOREIGN KEY (clue_id) REFERENCES clues(id)
)
`);
// Record that the team found this clue
await queryOne(
"INSERT INTO team_clues (team_id, clue_id) VALUES (?, ?)",
[token.teamId, clueId]
);
} catch (err) {
console.error("Error marking clue as found:", err);
// Continue even if there's an error recording the find
}
}
return Response.json( return Response.json(
{ clue }, {
{ headers: { "Content-Type": "application/json" } } ...clue,
found: !!found,
foundNow: !found,
},
{
headers: {
"Cache-Control": "no-store, no-cache, must-revalidate",
Pragma: "no-cache",
Expires: "0",
},
}
); );
} catch (error) { } catch (error) {
console.error("Error fetching clue details:", error); console.error("Error fetching clue:", error);
return Response.json( return Response.json(
{ error: "Failed to fetch clue details: " + error.message, clue: null }, { error: "Failed to fetch clue: " + error.message },
{ status: 500, headers: { "Content-Type": "application/json" } } { status: 500 }
); );
} }
} }
+43 -107
View File
@@ -1,127 +1,63 @@
import { query, queryOne, run } from "@/lib/db"; import { query, queryOne } from "@/lib/db";
import { cookies } from "next/headers"; import { getToken } from "next-auth/jwt";
import { verifyToken } from "@/app/api/auth/[...nextauth]/route";
export const dynamic = "force-dynamic"; // Disable caching export const dynamic = "force-dynamic"; // Disable caching
// Helper function to ensure the clues table exists
async function ensureCluesTableExists() {
try {
await run(`
CREATE TABLE IF NOT EXISTS clues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL,
location TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
return true;
} catch (error) {
console.error("Error ensuring clues table exists:", error);
return false;
}
}
// Helper function to extract numeric value from clue title
function extractClueNumber(title) {
// Look for patterns like "Clue 1", "Clue 10", etc.
const match = title.match(/Clue\s+(\d+)/i);
// If we find a match, return the number, otherwise return Infinity (to sort to the end)
return match ? parseInt(match[1], 10) : Infinity;
}
// GET endpoint to retrieve all clues
export async function GET(request) { export async function GET(request) {
try { try {
// First check if the hunt is active const token = await getToken({
const huntActiveSetting = await queryOne( req: request,
"SELECT value FROM settings WHERE key = 'hunt_active'" secret:
); process.env.NEXTAUTH_SECRET ||
const huntActive = huntActiveSetting && huntActiveSetting.value === "true"; "your-fallback-secret-should-be-at-least-32-chars",
});
// If hunt is active, allow access without authentication if (!token) {
if (!huntActive) { return Response.json({ error: "Unauthorized" }, { status: 401 });
// Only verify authentication when hunt is not active
const cookieStore = await cookies();
const sessionToken =
cookieStore.get("next-auth.session-token")?.value ||
cookieStore.get("__Secure-next-auth.session-token")?.value;
if (!sessionToken) {
return Response.json(
{ error: "Unauthorized", clues: [] },
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
const userData = verifyToken(sessionToken);
if (!userData) {
return Response.json(
{ error: "Invalid session", clues: [] },
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
// Admin can see clues even if hunt is not active
if (userData.role !== "admin") {
return Response.json(
{ error: "Hunt is not active", clues: [] },
{ headers: { "Content-Type": "application/json" } }
);
}
} }
// Ensure the clues table exists // Check if user has a team
await ensureCluesTableExists(); if (!token.teamId) {
// Fetch all clues without sorting in SQL
const clues = await query("SELECT * FROM clues");
// If no clues are found, insert sample clues (for testing purposes)
if (!clues || clues.length === 0) {
// Insert sample clues
await run(
"INSERT INTO clues (title, description, location) VALUES (?, ?, ?)",
["Clue 1", "This is the first clue description", "Location 1"]
);
await run(
"INSERT INTO clues (title, description, location) VALUES (?, ?, ?)",
["Clue 2", "This is the second clue description", "Location 2"]
);
await run(
"INSERT INTO clues (title, description, location) VALUES (?, ?, ?)",
["Clue 3", "This is the final clue description", "Location 3"]
);
// Fetch the inserted clues
const sampleClues = await query("SELECT * FROM clues");
// Sort clues based on numbering in title
sampleClues.sort(
(a, b) => extractClueNumber(a.title) - extractClueNumber(b.title)
);
return Response.json( return Response.json(
{ clues: sampleClues }, { error: "You must be part of a team to view clues" },
{ headers: { "Content-Type": "application/json" } } { status: 403 }
); );
} }
// Sort clues based on numbering in title // Get all clues
clues.sort( const clues = await query(`
(a, b) => extractClueNumber(a.title) - extractClueNumber(b.title) SELECT id, title, description, location, x_coord, y_coord
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) => {
const found = await queryOne(
"SELECT 1 FROM team_clues WHERE team_id = ? AND clue_id = ?",
[token.teamId, clue.id]
);
return {
...clue,
found: !!found,
};
})
); );
return Response.json( return Response.json(cluesWithStatus, {
{ clues }, headers: {
{ headers: { "Content-Type": "application/json" } } "Cache-Control": "no-store, no-cache, must-revalidate",
); Pragma: "no-cache",
Expires: "0",
},
});
} catch (error) { } catch (error) {
console.error("Error fetching clues:", error); console.error("Error fetching clues:", error);
return Response.json( return Response.json(
{ error: "Failed to fetch clues: " + error.message, clues: [] }, { error: "Failed to fetch clues: " + error.message },
{ status: 500, headers: { "Content-Type": "application/json" } } { status: 500 }
); );
} }
} }
+15 -2
View File
@@ -62,6 +62,7 @@ export async function GET(request) {
let isActive = false; let isActive = false;
let startTime = null; let startTime = null;
let endTime = null; let endTime = null;
let source = "none";
// Check all possible active indicators in priority order // Check all possible active indicators in priority order
if (timerStatus && Number(timerStatus.active) === 1) { if (timerStatus && Number(timerStatus.active) === 1) {
@@ -69,27 +70,39 @@ export async function GET(request) {
isActive = true; isActive = true;
startTime = timerStatus.start_time; startTime = timerStatus.start_time;
endTime = timerStatus.end_time; endTime = timerStatus.end_time;
source = "timer";
} else if (huntStatus && huntStatus.is_active) { } else if (huntStatus && huntStatus.is_active) {
console.log("Hunt active from hunt_status table"); console.log("Hunt active from hunt_status table");
isActive = true; isActive = true;
startTime = huntStatus.start_time; startTime = huntStatus.start_time;
endTime = huntStatus.end_time;
source = "hunt_status";
} else if (huntActiveSetting && huntActiveSetting.value === "true") { } else if (huntActiveSetting && huntActiveSetting.value === "true") {
console.log("Hunt active from settings table"); console.log("Hunt active from settings table");
isActive = true; isActive = true;
// If we have no start time but hunt is active, use current time
if (!startTime) {
startTime = new Date().toISOString();
}
source = "settings";
} else if (oldHuntRecord && oldHuntRecord.active) { } else if (oldHuntRecord && oldHuntRecord.active) {
console.log("Hunt active from hunts table"); console.log("Hunt active from hunts table");
isActive = true; isActive = true;
startTime = oldHuntRecord.start_time; startTime = oldHuntRecord.start_time;
endTime = oldHuntRecord.end_time;
source = "hunts";
} }
console.log("Hunt status check:", { isActive, startTime, endTime }); console.log("Hunt status check:", { isActive, startTime, endTime, source });
// Set extremely aggressive cache control headers // Always include all timing data for consistency
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
active: isActive, active: isActive,
startTime: startTime || null, startTime: startTime || null,
endTime: endTime || null, endTime: endTime || null,
source, // For debugging
timestamp: new Date().toISOString(), // For cache-busting
}), }),
{ {
status: 200, status: 200,
+18 -9
View File
@@ -90,10 +90,12 @@ async function ensureSettingsTableExists() {
} }
// Helper function to update hunt active status - ensure it updates all tables // Helper function to update hunt active status - ensure it updates all tables
async function updateHuntActiveStatus(isActive) { async function updateHuntActiveStatus(isActive, startTime = null) {
try { try {
await ensureSettingsTableExists(); await ensureSettingsTableExists();
const now = startTime || new Date().toISOString();
// Update settings table - this is used by many endpoints to check status // Update settings table - this is used by many endpoints to check status
await run("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", [ await run("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", [
"hunt_active", "hunt_active",
@@ -102,7 +104,6 @@ async function updateHuntActiveStatus(isActive) {
// Update hunt_status table for consistency // Update hunt_status table for consistency
try { try {
const now = new Date().toISOString();
await run(` await run(`
CREATE TABLE IF NOT EXISTS hunt_status ( CREATE TABLE IF NOT EXISTS hunt_status (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -121,11 +122,15 @@ async function updateHuntActiveStatus(isActive) {
[isActive ? 1 : 0, isActive ? now : null] [isActive ? 1 : 0, isActive ? now : null]
); );
} else { } else {
// Update existing record // Update existing record - but preserve start_time when stopping
await run( if (isActive) {
"UPDATE hunt_status SET is_active = ?, start_time = ? WHERE id = 1", await run(
[isActive ? 1 : 0, isActive ? now : null] "UPDATE hunt_status SET is_active = ?, start_time = ? WHERE id = 1",
); [1, now]
);
} else {
await run("UPDATE hunt_status SET is_active = ? WHERE id = 1", [0]);
}
} }
} catch (err) { } catch (err) {
console.error("Error updating hunt_status table:", err); console.error("Error updating hunt_status table:", err);
@@ -289,7 +294,7 @@ export async function POST(request) {
); );
// Update hunt active status to true // Update hunt active status to true
await updateHuntActiveStatus(true); await updateHuntActiveStatus(true, startTime);
return Response.json({ return Response.json({
success: true, success: true,
@@ -306,10 +311,13 @@ export async function POST(request) {
} }
} else if (action === "stop") { } else if (action === "stop") {
try { try {
// Get current time for the end timestamp
const endTime = new Date().toISOString();
// Update the most recent timer to inactive // Update the most recent timer to inactive
await run( await run(
"UPDATE hunt_timer SET active = 0, end_time = ? WHERE active = 1", "UPDATE hunt_timer SET active = 0, end_time = ? WHERE active = 1",
[new Date().toISOString()] [endTime]
); );
// Update hunt active status to false // Update hunt active status to false
@@ -318,6 +326,7 @@ export async function POST(request) {
return Response.json({ return Response.json({
success: true, success: true,
message: "Timer stopped successfully", message: "Timer stopped successfully",
endTime: endTime,
}); });
} catch (dbError) { } catch (dbError) {
console.error("Database error stopping timer:", dbError); console.error("Database error stopping timer:", dbError);
+14 -2
View File
@@ -1,5 +1,5 @@
import { getToken } from "next-auth/jwt"; import { getToken } from "next-auth/jwt";
import { queryOne } from "@/lib/db"; import { queryOne, run } from "@/lib/db";
export async function GET(request, context) { export async function GET(request, context) {
try { try {
@@ -14,7 +14,8 @@ export async function GET(request, context) {
return Response.json({ error: "Unauthorized" }, { status: 401 }); return Response.json({ error: "Unauthorized" }, { status: 401 });
} }
const { teamId } = context.params; // Properly await context.params
const { teamId } = await context.params;
// Validate that user belongs to this team // Validate that user belongs to this team
if (!token.teamId || parseInt(token.teamId, 10) !== parseInt(teamId, 10)) { if (!token.teamId || parseInt(token.teamId, 10) !== parseInt(teamId, 10)) {
@@ -24,6 +25,17 @@ export async function GET(request, context) {
); );
} }
// First ensure the team_hunt_sessions table exists
await run(`
CREATE TABLE IF NOT EXISTS team_hunt_sessions (
team_id INTEGER PRIMARY KEY,
start_time TEXT NOT NULL,
initiated_by INTEGER,
FOREIGN KEY (team_id) REFERENCES teams(id),
FOREIGN KEY (initiated_by) REFERENCES users(id)
)
`);
// Get the team's hunt session from the database // Get the team's hunt session from the database
const session = await queryOne( const session = await queryOne(
"SELECT team_id, start_time FROM team_hunt_sessions WHERE team_id = ?", "SELECT team_id, start_time FROM team_hunt_sessions WHERE team_id = ?",
+9 -7
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import styles from "./waiting.module.css"; import styles from "./waiting.module.css";
import { setHuntTimer, clearHuntTimer } from "@/utils/timerService";
export default function WaitingPage() { export default function WaitingPage() {
const router = useRouter(); const router = useRouter();
@@ -52,11 +53,8 @@ export default function WaitingPage() {
if (teamHuntResponse.ok) { if (teamHuntResponse.ok) {
const teamHuntData = await teamHuntResponse.json(); const teamHuntData = await teamHuntResponse.json();
if (teamHuntData.startTime) { if (teamHuntData.startTime) {
// Store the start time for the map page // Store the start time in the centralized timer service
localStorage.setItem( setHuntTimer(teamHuntData.startTime);
"teamHuntStartTime",
teamHuntData.startTime
);
console.log("Team hunt is active! Redirecting to map..."); console.log("Team hunt is active! Redirecting to map...");
redirectAttempted = true; redirectAttempted = true;
@@ -92,6 +90,10 @@ export default function WaitingPage() {
// Hunt was active before but now it's not - it has ended // Hunt was active before but now it's not - it has ended
console.log("Hunt has ended! Showing leaderboard..."); console.log("Hunt has ended! Showing leaderboard...");
setHuntEnded(true); setHuntEnded(true);
// Clear any timer data
clearHuntTimer();
// Don't redirect immediately to allow user to read the message // Don't redirect immediately to allow user to read the message
} }
@@ -99,9 +101,9 @@ export default function WaitingPage() {
setLastHuntData(data); setLastHuntData(data);
if (data.active) { if (data.active) {
// Store the global start time // Store the global start time in the centralized timer service
if (data.startTime) { if (data.startTime) {
localStorage.setItem("teamHuntStartTime", data.startTime); setHuntTimer(data.startTime, data.endTime);
} }
console.log("Hunt is active! Redirecting immediately..."); console.log("Hunt is active! Redirecting immediately...");
BIN
View File
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
/**
* Centralized timer service to ensure consistent timing across the application
*/
// Store the hunt timer information in localStorage for persistence
export const setHuntTimer = (startTime, endTime = null, isActive = true) => {
const timerData = {
startTime,
endTime,
isActive,
lastUpdated: new Date().toISOString(),
};
localStorage.setItem("huntTimerData", JSON.stringify(timerData));
};
// Clear the hunt timer completely
export const clearHuntTimer = () => {
localStorage.removeItem("huntTimerData");
localStorage.removeItem("teamHuntStartTime"); // Remove legacy timer as well
};
// Get the current hunt timer data
export const getHuntTimer = () => {
try {
const timerData = localStorage.getItem("huntTimerData");
if (!timerData) {
// Try to migrate from old format if exists
const legacyStartTime = localStorage.getItem("teamHuntStartTime");
if (legacyStartTime) {
return {
startTime: legacyStartTime,
endTime: null,
isActive: true,
lastUpdated: new Date().toISOString(),
};
}
return null;
}
return JSON.parse(timerData);
} catch (error) {
console.error("Error parsing hunt timer data:", error);
return null;
}
};
// Calculate and format elapsed time from a given start time
export const formatElapsedTime = (startTimeStr) => {
if (!startTimeStr) return "00:00:00";
const startTime = new Date(startTimeStr).getTime();
const now = Date.now();
const elapsed = Math.floor((now - startTime) / 1000);
// Handle negative time (shouldn't happen in normal usage)
if (elapsed < 0) return "00:00:00";
const hours = Math.floor(elapsed / 3600)
.toString()
.padStart(2, "0");
const minutes = Math.floor((elapsed % 3600) / 60)
.toString()
.padStart(2, "0");
const seconds = Math.floor(elapsed % 60)
.toString()
.padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
};
// Hook function to use in components to get formatted elapsed time
export const useElapsedTime = (startTime, isActive = true) => {
if (!startTime || !isActive) return "00:00:00";
return formatElapsedTime(startTime);
};