diff options
author | Nat Lasseter <user@4574.co.uk> | 2025-04-01 11:45:48 +0100 |
---|---|---|
committer | Nat Lasseter <user@4574.co.uk> | 2025-04-01 11:45:48 +0100 |
commit | 293df027348307de896232f1b867dd220ae8caa3 (patch) | |
tree | d63fedaadde32ef336f3004efb99c6dcf1f23585 /static/bs/bracket.js | |
parent | e26f3cdaf3919375b8ba825ee1a486d229f21def (diff) |
[bracket] rename
Diffstat (limited to 'static/bs/bracket.js')
-rw-r--r-- | static/bs/bracket.js | 4071 |
1 files changed, 0 insertions, 4071 deletions
diff --git a/static/bs/bracket.js b/static/bs/bracket.js deleted file mode 100644 index 59167b9..0000000 --- a/static/bs/bracket.js +++ /dev/null @@ -1,4071 +0,0 @@ -class BracketCityRank { - /** - * @typedef {Object} RankConfig - * @property {number} peekPenalty - Points deducted per peek - * @property {number} megaPeekPenalty - Points deducted per mega peek - * @property {number} wrongGuessPenalty - Points deducted per wrong guess - * @property {Object.<string, number>} rankThresholds - Score thresholds for each rank - */ - - /** - * @param {Partial<RankConfig>} config - Optional configuration overrides - */ - constructor(config = {}) { - // Define rank emojis with consistent Unicode escapes - this.rankEmojis = { - 'Tourist': '\u{1F4F8}', // Camera with flash - 'Commuter': '\u{1F682}', // Steam locomotive - 'Resident': '\u{1F3E0}', // House - 'Council Member': '\u{1F3DB}', // Classical building - 'Chief of Police': '\u{1F46E}', // Police officer - 'Mayor': '\u{1F396}', // Military medal - 'Power Broker': '\u{1F4BC}', // Briefcase - 'Kingmaker': '\u{1F451}', // Crown - 'Puppet Master': '\u{1F52E}' // Crystal ball - }; - - // Default configuration - const defaultConfig = { - peekPenalty: 5, - megaPeekPenalty: 15, - wrongGuessPenalty: 2, // For regular mode - rankThresholds: { - 'Tourist': 0, - 'Commuter': 11, - 'Resident': 21, - 'Council Member': 31, - 'Chief of Police': 51, - 'Mayor': 68, - 'Power Broker': 79, - 'Kingmaker': 91, - 'Puppet Master': 100 - }, - regularRankThresholds: { - 'Tourist': 0, - 'Commuter': 10, - 'Resident': 20, - 'Council Member': 45, - 'Chief of Police': 65, - 'Mayor': 80, - 'Power Broker': 90, - 'Kingmaker': 100, - 'Puppet Master': 100 - } - }; - - // Merge provided config with defaults - this.config = { - ...defaultConfig, - ...config, - // Ensure rankThresholds is fully merged - rankThresholds: { - ...defaultConfig.rankThresholds, - ...(config.rankThresholds || {}) - }, - regularRankThresholds: { - ...defaultConfig.regularRankThresholds, - ...(config.regularRankThresholds || {}) - } - }; - } - - /** - * Modified calculateScore method to account for different scoring systems - * @param {number} efficiency - Base efficiency score (0-100) - * @param {number} peekCount - Number of peeks used - * @param {number} megaPeekCount - Number of mega peeks used - * @param {number} wrongGuessCount - Number of wrong guesses - * @returns {Object} Detailed score breakdown - */ - calculateScore(efficiency, peekCount = 0, megaPeekCount = 0, wrongGuessCount = 0) { - // Always ensure inputs are numbers and non-negative - const safepeekCount = Math.max(0, Number(peekCount) || 0); - const safeMegaPeekCount = Math.max(0, Number(megaPeekCount) || 0); - const safeWrongGuessCount = Math.max(0, Number(wrongGuessCount) || 0); - - // Calculate penalties - const peekPenalty = safepeekCount * this.config.peekPenalty; - const megaPeekPenalty = safeMegaPeekCount * this.config.megaPeekPenalty; - - // Different base score calculation based on mode - let baseScore, wrongGuessPenalty, totalPenalty, finalScore; - - if (this.inputMode === 'classic') { - // Classic mode: score based on keystroke efficiency - baseScore = Math.max(0, Number(efficiency) || 0); - wrongGuessPenalty = 0; // No wrong guess penalty in classic mode - totalPenalty = peekPenalty + megaPeekPenalty; - } else { - // Submit mode: score starts at 100, penalty for wrong guesses - baseScore = 100; // Start at max - wrongGuessPenalty = safeWrongGuessCount * this.config.wrongGuessPenalty; - totalPenalty = peekPenalty + megaPeekPenalty + wrongGuessPenalty; - } - - // Calculate final score (capped at 100) - finalScore = Math.min(100, Math.max(0, baseScore - totalPenalty)); - - return { - baseScore, - peekPenalty, - megaPeekPenalty, - wrongGuessPenalty, - totalPenalty, - finalScore - }; - } - - /** - * Generates detailed stats including rank and scoring information - * Accounts for different modes - * @param {number} efficiency - Base efficiency score - * @param {number} peekCount - Number of peeks used - * @param {number} megaPeekCount - Number of mega peeks used - * @param {number} wrongGuessCount - Number of wrong guesses - * @returns {Object} Detailed stats object - */ - getDetailedStats(efficiency, peekCount = 0, megaPeekCount = 0, wrongGuessCount = 0) { - const scoreDetails = this.calculateScore(efficiency, peekCount, megaPeekCount, wrongGuessCount); - const rank = this.calculateRank(efficiency, peekCount, megaPeekCount, wrongGuessCount); - - // Calculate points needed for next rank - const thresholds = this.inputMode === 'classic' ? - this.config.rankThresholds : - this.config.regularRankThresholds; - - const ranks = Object.entries(thresholds) - .sort((a, b) => a[1] - b[1]); // Sort ascending - - const currentRankIndex = ranks.findIndex(([r]) => r === rank); - const nextRank = currentRankIndex < ranks.length - 1 ? - ranks[currentRankIndex + 1] : null; - - const pointsToNextRank = nextRank ? - Math.max(0, nextRank[1] - scoreDetails.finalScore) : 0; - - return { - rank, - rankEmoji: this.rankEmojis[rank] || this.rankEmojis['Tourist'], - ...scoreDetails, - nextRankName: nextRank ? nextRank[0] : null, - pointsToNextRank: Math.ceil(pointsToNextRank), // Round up for user-friendly display - config: this.config - }; - } - - /** - * Determines rank based on final score - * @param {number} efficiency - Base efficiency score - * @param {number} peekCount - Number of peeks used - * @param {number} megaPeekCount - Number of mega peeks used - * @param {number} wrongGuessCount - Number of wrong guesses - * @returns {string} Rank title - */ - calculateRank(efficiency, peekCount = 0, megaPeekCount = 0, wrongGuessCount = 0) { - const { finalScore } = this.calculateScore(efficiency, peekCount, megaPeekCount, wrongGuessCount); - - // Only keep the Puppet Master special case - // Puppet Master: 100% keystroke-efficient (no excess keystrokes) AND no peeks/wrong guesses - const isPerfect = efficiency === 100; - const isNoHelp = peekCount === 0 && megaPeekCount === 0 && wrongGuessCount === 0; - - if (isPerfect && isNoHelp) { - return 'Puppet Master'; - } - - // For all other cases, follow normal threshold logic - const thresholds = this.inputMode === 'classic' ? - this.config.rankThresholds : - this.config.regularRankThresholds; - - const ranks = Object.entries(thresholds) - .sort((a, b) => b[1] - a[1]); - - // Find highest rank threshold that score exceeds - const rank = ranks.find(([_, threshold]) => finalScore >= threshold); - return rank ? rank[0] : 'Tourist'; - } -} - - /** - * @typedef {Object} GameState - * @property {string} displayState - Current puzzle display with HTML markup - * @property {Set<string>} solvedExpressions - Set of solved expression strings - * @property {string} message - Current user message - * @property {number} totalKeystrokes - Total keystrokes used - * @property {number} minimumKeystrokes - Minimum keystrokes needed - * @property {Array<{start: number, end: number, text: string, expression: string}>} activeClues - * @property {Set<string>} hintModeClues - Clues that have been peeked - * @property {Set<string>} peekedClues - Tracking for peek penalties - * @property {Set<string>} megaPeekedClues - Clues that were fully revealed - */ - - - class BracketPuzzle { - - /**************************** - Core initilization methods - ****************************/ - - /** - * Constructor and Initialization - */ - - constructor(rootElement) { - this.root = rootElement; - this.API_ENDPOINT = 'https://user.4574.co.uk/bracket-api'; - this.PUZZLE_DATA = null; - - this.MODE_STORAGE_KEY = 'bracketCityInputMode'; - this.inputMode = this.getInputMode(); - - // Detect device type - this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - - // Add new property specifically for input method decision - this.useCustomKeyboard = this.isMobile && !this.isTablet(); - - // Track keyboard layout - this.isAltKeyboard = false; - - // Initialize user ID - this.userId = this.initializeUserId(); - - // Initialize empty state immediately - this.state = { - displayState: '', - solvedExpressions: new Set(), - message: '', - totalKeystrokes: 0, - minimumKeystrokes: 0, - activeClues: [], - hintModeClues: new Set(), - peekedClues: new Set(), - megaPeekedClues: new Set(), - wrongGuesses: 0 - }; - - // Initialize rank calculator - this.rankCalculator = new BracketCityRank(); - this.rankCalculator.inputMode = this.inputMode; - - // Start initialization - this.initializeGame(); - } - - /** - * Initializes the game - */ - async initializeGame() { - try { - console.log('Starting game initialization'); - - // Show loading state immediately - this.root.innerHTML = this.generateLoadingHTML(); - - // Get initial puzzle date, checking encoded puzzles first - const urlParams = new URLSearchParams(window.location.search); - const encoded = urlParams.get('d'); - - if (encoded) { - const decoded = PuzzleEncoder.decodePuzzle(encoded); - if (decoded) { - this.currentPuzzleDate = decoded.puzzleDate; - } else { - this.currentPuzzleDate = this.getPuzzleDateFromURL(); - } - } else { - this.currentPuzzleDate = this.getPuzzleDateFromURL(); - } - - console.log('Current puzzle date set to:', this.currentPuzzleDate); - - // Load saved input mode preference - this.inputMode = this.getInputMode(); - - // Update rankCalculator with the input mode - this.rankCalculator = new BracketCityRank(); - this.rankCalculator.inputMode = this.inputMode; - - // Start showing basic UI elements - this.root.innerHTML = this.generateInitialHTML(); - this.setupInputHandlers(); - - // Load puzzle data - console.log('Loading puzzle data'); - await this.initializePuzzleData(); - - if (!this.PUZZLE_DATA) { - throw new Error('Failed to initialize puzzle data'); - } - - // Load or initialize game state - console.log('Loading saved state'); - const savedState = await this.loadSavedState(this.currentPuzzleDate); - console.log('Saved state loaded:', savedState ? 'found' : 'not found'); - - // Initialize game state - if (savedState && savedState.initialPuzzle === this.PUZZLE_DATA.initialPuzzle) { - console.log('Using saved state'); - this.updateGameState({ - displayState: savedState.displayState, - solvedExpressions: new Set(savedState.solvedExpressions), - message: savedState.message || '', - totalKeystrokes: savedState.totalKeystrokes, - minimumKeystrokes: savedState.minimumKeystrokes, - activeClues: this.findActiveClues(savedState.displayState), - hintModeClues: new Set(savedState.hintModeClues || []), - peekedClues: new Set(savedState.peekedClues || []), - megaPeekedClues: new Set(savedState.megaPeekedClues || []), - wrongGuesses: savedState.wrongGuesses || 0 - }); - } else { - console.log('Initializing fresh state'); - this.updateGameState({ - displayState: this.PUZZLE_DATA.initialPuzzle, - solvedExpressions: new Set(), - message: '', - totalKeystrokes: 0, - minimumKeystrokes: this.calculateMinimumKeystrokes(), - activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle), - hintModeClues: new Set(), - peekedClues: new Set(), - megaPeekedClues: new Set(), - wrongGuesses: 0 - }); - } - - this.setupDatePicker(); - - // Update URL to match current puzzle - if (!encoded) { // Only update URL if not an encoded puzzle - this.updateURL(this.currentPuzzleDate); - } - - // Do the initial render - console.log('Performing initial render'); - this.render(); - - // Load non-critical features - requestAnimationFrame(() => { - console.log('Setting up auxiliary features'); - this.setupShareButtons(); - this.setupPuzzleDisplay(); - - this.checkAdjacentPuzzles(this.currentPuzzleDate).then(() => { - this.setupNavigationHandlers(); - console.log('Navigation setup complete'); - }); - }); - // Show important announcement after UI is fully rendered - // Change the id parameter for each new announcement to ensure it shows - this.showImportantAnnouncement({ - title: '\u{1F6A8} Welcome to [Bracket Shitty] \u{1F6A8}', - message: ` - <ul style="text-align: left; margin: 1rem 0; padding-left: 1.5rem;"> - <li>Click <span class='help-icon'>?</span> to access the <mark style="background-color:rgba(255, 255, 0, 0.2);">tutorial</mark></li> - <li>Click <span class='help-icon'>!</span> to access <mark style="background-color:rgba(255, 255, 0, 0.2);">news and settings</mark></li> - <li>Click the date header to access the <mark style="background-color:rgba(255, 255, 0, 0.2);">date picker</mark> and browse the archive</li> - </ul> - <p><strong>Thank you for playing Bracket Shitty!</strong></p> - `, - buttonText: 'ok great thanks', - id: 'update-2025-3-11' // Change this ID for a new announcement - }); - - - } catch (error) { - console.error('Failed to initialize game:', error); - this.showErrorMessage('Failed to load the game. Please refresh the page to try again.'); - } - } - - /** - * Initializes or retrieves the user ID - * @returns {string} User ID - */ - initializeUserId() { - const storageKey = 'bracketCityUserId'; - let userId = localStorage.getItem(storageKey); - - if (!userId) { - userId = crypto.randomUUID(); - localStorage.setItem(storageKey, userId); - } - - return userId; - } - - async initializePuzzleData() { - const urlParams = new URLSearchParams(window.location.search); - const encoded = urlParams.get('d'); - - if (encoded) { - // Try PuzzleEncoder format first - const decodedPuzzle = PuzzleEncoder.decodePuzzle(encoded); - if (decodedPuzzle) { - this.PUZZLE_DATA = decodedPuzzle; - } else { - // Try direct base64 JSON format - try { - const jsonStr = atob(encoded); - const puzzleData = JSON.parse(jsonStr); - - // Validate required fields - if (puzzleData.initialPuzzle && puzzleData.solutions) { - this.PUZZLE_DATA = { - initialPuzzle: puzzleData.initialPuzzle, - solutions: puzzleData.solutions - }; - } else { - console.error('Invalid puzzle data structure in URL'); - await this.fetchPuzzleForDate(this.currentPuzzleDate); - } - } catch (e) { - console.error('Failed to decode puzzle data:', e); - await this.fetchPuzzleForDate(this.currentPuzzleDate); - } - } - } else { - // No encoded puzzle, fetch for specific date or today - await this.fetchPuzzleForDate(this.currentPuzzleDate); - } - - if (!this.PUZZLE_DATA) { - throw new Error('Failed to initialize puzzle data'); - } - } - - /** - * Checks if running in development environment - */ - isDevelopmentEnvironment() { - const hostname = window.location.hostname; - return hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === 'staging.bracket.city' || - hostname === 'dev.bracket.city'; - } - - - /** - * Gets the current input mode from localStorage or defaults to 'classic' - * @returns {string} The input mode ('classic' or 'submit') - */ - getInputMode() { - return localStorage.getItem(this.MODE_STORAGE_KEY) || 'submit'; - } - - /** - * Determines if a puzzle has been meaningfully started based on consistent criteria - * @param {Object} puzzleData - The puzzle state to check - * @returns {boolean} Whether the puzzle is considered started - */ - isPuzzleStarted(puzzleData = this.state) { - // If no data provided or using current state, check directly - if (puzzleData === this.state) { - return (this.state.totalKeystrokes > 5) || - (this.state.peekedClues && this.state.peekedClues.size > 0) || - (this.state.megaPeekedClues && this.state.megaPeekedClues.size > 0) || - (this.state.wrongGuesses > 0) || - (this.state.solvedExpressions && this.state.solvedExpressions.size > 0); - } - - // For saved state data from localStorage (which has arrays instead of Sets) - return (puzzleData.totalKeystrokes > 5) || - (puzzleData.peekedClues && puzzleData.peekedClues.length > 0) || - (puzzleData.megaPeekedClues && puzzleData.megaPeekedClues.length > 0) || - (puzzleData.wrongGuesses > 0) || - (puzzleData.solvedExpressions && puzzleData.solvedExpressions.length > 0); - } - - /** - * Sets the input mode and updates the UI - * @param {string} mode - The input mode to set ('classic' or 'submit') - */ - setInputMode(mode) { - if (mode !== 'classic' && mode !== 'submit') { - console.error('Invalid input mode:', mode); - return; - } - - // Check if the puzzle has been started - const hasPuzzleStarted = this.isPuzzleStarted(); - - // Only allow mode changes if the puzzle hasn't been started - if (hasPuzzleStarted) { - // Don't show a message - the disabled toggle with explanatory text is sufficient - console.log('Mode change prevented: puzzle already started'); - return; - } - - this.inputMode = mode; - localStorage.setItem(this.MODE_STORAGE_KEY, mode); - - // Update rankCalculator's input mode - this.rankCalculator.inputMode = mode; - - // Update the UI to reflect the new mode - this.updateInputModeUI(); - - // If help/info is currently visible, ensure inputs stay disabled - if (this.state.helpVisible) { - this.disableInputsForHelp(); - } - } - - /** - * New helper method to ensure inputs are disabled when help is visible - */ - disableInputsForHelp() { - // Get the input elements - const desktopInput = this.root.querySelector('.answer-input'); - - // Disable input while help is visible - if (desktopInput) { - desktopInput.disabled = true; - } - - // Disable submit button (desktop) - const submitButton = this.root.querySelector('.submit-answer'); - if (submitButton) { - submitButton.disabled = true; - submitButton.style.opacity = '0.5'; - submitButton.style.pointerEvents = 'none'; - } - - // For mobile, disable keyboard and input without hiding - if (this.isMobile) { - // Make custom input appear disabled - const customInput = this.root.querySelector('.custom-input'); - if (customInput) { - customInput.style.opacity = '0.5'; - customInput.style.pointerEvents = 'none'; - } - - // Disable all keyboard keys - const keyboardKeys = this.root.querySelectorAll('.keyboard-key'); - keyboardKeys.forEach(key => { - key.disabled = true; - key.style.opacity = '0.5'; - key.style.pointerEvents = 'none'; - }); - - // Disable mobile submit button - const mobileSubmitButton = this.root.querySelector('.mobile-submit-button'); - if (mobileSubmitButton) { - mobileSubmitButton.disabled = true; - mobileSubmitButton.style.opacity = '0.5'; - mobileSubmitButton.style.pointerEvents = 'none'; - } - } - } - - /** - * Updates the UI to match the current input mode - */ - updateInputModeUI() { - // Replace the input container HTML - const inputContainer = this.root.querySelector('.input-container'); - if (!inputContainer) return; - - // Store current input value before replacing the container - let currentInputValue = ''; - if (this.isMobile) { - currentInputValue = this.customInputValue || ''; - } else { - const inputEl = this.root.querySelector('.answer-input'); - currentInputValue = inputEl ? inputEl.value : ''; - } - - // Generate fresh input container HTML based on current mode - inputContainer.innerHTML = this.generateInputContainerHTML(); - - // Re-setup input handlers - this.setupInputHandlers(); - - // Restore input value - if (this.isMobile) { - this.customInputValue = currentInputValue; - this.updateCustomInputDisplay(); - } else { - const inputEl = this.root.querySelector('.answer-input'); - if (inputEl) inputEl.value = currentInputValue; - } - - // Re-render to show updated stats if the puzzle is complete - if (this.isPuzzleComplete()) { - const keystrokeStats = this.root.querySelector('.keystroke-stats'); - if (keystrokeStats) { - this.renderCompletionStats(keystrokeStats); - } - } - } - /**************** - State Management - *****************/ - - /** - * Updates game state while maintaining consistency - * @param {Partial<GameState>} updates - State properties to update - */ - updateGameState(updates) { - // Create new state with updates - const newState = { - ...this.state, - ...updates - }; - - // Ensure Sets remain Sets - if (updates.solvedExpressions) { - newState.solvedExpressions = new Set(updates.solvedExpressions); - } - if (updates.hintModeClues) { - newState.hintModeClues = new Set(updates.hintModeClues); - } - if (updates.peekedClues) { - newState.peekedClues = new Set(updates.peekedClues); - } - if (updates.megaPeekedClues) { - newState.megaPeekedClues = new Set(updates.megaPeekedClues); - } - - // Update active clues if display state changes - if (updates.displayState) { - newState.activeClues = this.findActiveClues(updates.displayState); - } - - // Update state and persist - this.state = newState; - this.saveState(); - } - - /** - * Gets the storage key for the current puzzle - enhanced version with explicit date parameter - * @param {string} puzzleDate - Optional specific puzzle date - * @returns {string} Storage key - */ - getStorageKey(puzzleDate) { - const dateToUse = puzzleDate || this.currentPuzzleDate; - if (!dateToUse) { - console.error('No puzzle date available when getting storage key!'); - return null; - } - return `bracketPuzzle_${dateToUse}`; - } - - /** - * Enhanced loadSavedState method with input mode handling - * @param {string} targetDate - The puzzle date to load state for - * @returns {Object|null} Saved state or null if none exists or validation fails - */ - async loadSavedState(targetDate) { - try { - // Ensure we're loading state for the correct puzzle date - const puzzleDate = targetDate || this.currentPuzzleDate; - - if (!puzzleDate) { - console.warn('Cannot load state: missing puzzle date'); - return null; - } - - const storageKey = `bracketPuzzle_${puzzleDate}`; - - if (!this.PUZZLE_DATA) { - console.warn('Cannot load state: missing puzzle data'); - return null; - } - - const savedData = localStorage.getItem(storageKey); - if (!savedData) { - console.log('No saved state found for key:', storageKey); - return null; - } - - const parsed = JSON.parse(savedData); - - // Enhanced validation: verify the puzzle date in the saved state matches the requested date - if (parsed.puzzleDate !== puzzleDate) { - console.warn(`Saved state puzzle date (${parsed.puzzleDate}) doesn't match requested date (${puzzleDate})`); - return null; - } - - // Additional validation: verify the initialPuzzle matches - if (parsed.initialPuzzle !== this.PUZZLE_DATA.initialPuzzle) { - console.warn('Saved state initialPuzzle doesn\'t match current puzzle data'); - return null; - } - - return { - displayState: parsed.displayState, - solvedExpressions: parsed.solvedExpressions || [], - message: '', // Always start with empty message - messageType: null, - totalKeystrokes: parsed.totalKeystrokes || 0, - minimumKeystrokes: parsed.minimumKeystrokes || this.calculateMinimumKeystrokes(), - hintModeClues: parsed.hintModeClues || [], - peekedClues: parsed.peekedClues || [], - megaPeekedClues: parsed.megaPeekedClues || [], - initialPuzzle: parsed.initialPuzzle, - puzzleDate: parsed.puzzleDate, - wrongGuesses: parsed.wrongGuesses || 0, - // Include the saved input mode if it exists - inputMode: parsed.inputMode || this.getInputMode(), - // Preserve help system state properties if they exist - helpVisible: parsed.helpVisible || false, - previousDisplay: parsed.previousDisplay || null - }; - - } catch (error) { - console.error('Error loading saved state:', error); - return null; - } - } - - saveState() { - try { - const storageKey = this.getStorageKey(); - if (!storageKey) { - console.error('Cannot save state: missing storage key'); - return false; - } - - const stateToSave = { - displayState: this.state.displayState, - solvedExpressions: Array.from(this.state.solvedExpressions), - totalKeystrokes: this.state.totalKeystrokes, - minimumKeystrokes: this.state.minimumKeystrokes, - hintModeClues: Array.from(this.state.hintModeClues), - peekedClues: Array.from(this.state.peekedClues), - megaPeekedClues: Array.from(this.state.megaPeekedClues), - initialPuzzle: this.PUZZLE_DATA.initialPuzzle, - puzzleDate: this.currentPuzzleDate, - isComplete: this.state.isComplete || false, - completionStats: this.state.completionStats || null, - wrongGuesses: this.state.wrongGuesses || 0, - inputMode: this.inputMode // Save the input mode with puzzle state - }; - - localStorage.setItem(storageKey, JSON.stringify(stateToSave)); - return true; - - } catch (error) { - console.error('Error saving state:', error); - return false; - } - } - - - /** - * Resets puzzle state and storage - */ - resetPuzzle() { - if (!confirm('Are you sure you want to reset the puzzle? All progress will be lost.')) { - return; - } - - // Clear saved state. - const storageKey = this.getStorageKey(); - if (storageKey) { - localStorage.removeItem(storageKey); - } - - // Get the user's preferred input mode - const preferredInputMode = this.getInputMode(); - - // Update the current input mode to the preferred mode - this.inputMode = preferredInputMode; - - // Update the rankCalculator's input mode - this.rankCalculator.inputMode = this.inputMode; - - // Reset game state - this.updateGameState({ - displayState: this.PUZZLE_DATA.initialPuzzle, - solvedExpressions: new Set(), - message: '', - totalKeystrokes: 0, - minimumKeystrokes: this.calculateMinimumKeystrokes(), - activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle), - hintModeClues: new Set(), - peekedClues: new Set(), - megaPeekedClues: new Set(), - helpVisible: false, - previousDisplay: null, - wrongGuesses: 0 // Reset wrong guesses - }); - - // Remove any existing completion message and "completed" classes. - const completionMessage = this.root.querySelector('.completion-message'); - if (completionMessage) { - completionMessage.remove(); - } - const container = this.root.querySelector('.puzzle-container'); - if (container) { - container.classList.remove('completed'); - } - const puzzleDisplay = this.root.querySelector('.puzzle-display'); - if (puzzleDisplay) { - puzzleDisplay.classList.remove('completed'); - } - - // Rebuild the UI within your dedicated container. - this.root.innerHTML = this.generateInitialHTML(); - - // Re‑attach event handlers. - this.setupInputHandlers(); - this.setupShareButtons(); - this.setupPuzzleDisplay(); - this.setupDatePicker(); - // Re‑attach navigation handlers. Use a timeout to allow the new DOM to settle. - setTimeout(() => { - this.setupNavigationHandlers(); - this.checkAdjacentPuzzles(this.currentPuzzleDate); - }, 0); - - // Make sure the input container is visible. - const inputContainer = this.root.querySelector('.input-container'); - if (inputContainer) { - inputContainer.style.display = 'block'; - } - if (!this.isMobile && this.root.querySelector('.answer-input')) { - this.root.querySelector('.answer-input').focus(); - } - - this.render(); - this.showMessage('Puzzle reset successfully!'); - } - - calculateMinimumKeystrokes() { - return Object.values(this.PUZZLE_DATA.solutions) - .reduce((total, solution) => total + solution.length, 0); - } - - /************* - Data and API - **************/ - - /** - * Fetches and processes puzzle data for a date - */ - async fetchPuzzleForDate(targetDate) { - try { - // Try to get puzzle for specified date - let response = await fetch(`${this.API_ENDPOINT}/${targetDate}`, { - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' - } - }); - - // If that date's puzzle doesn't exist, get latest puzzle - if (!response.ok) { - // Get base list of puzzles - response = await fetch(this.API_ENDPOINT, { - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' - } - }); - if (!response.ok) { - throw new Error('Unable to fetch puzzle list'); - } - - const puzzles = await response.json(); - if (!puzzles || puzzles.length === 0) { - throw new Error('No puzzles available'); - } - - // Find the most recent puzzle date - const latestPuzzleDate = puzzles.reduce((latest, current) => { - return latest > current ? latest : current; - }); - - // Fetch the latest puzzle - response = await fetch(`${this.API_ENDPOINT}/${latestPuzzleDate}`, { - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' - } - }); - if (!response.ok) { - throw new Error('Failed to fetch latest puzzle'); - } - } - - const puzzleData = await response.json(); - - /*console.log('Raw puzzle data received:', { - date: targetDate, - puzzleData, - timestamp: new Date().toISOString() - });*/ - - // Clean up escaped quotes and store in PUZZLE_DATA - this.PUZZLE_DATA = { - ...puzzleData, - initialPuzzle: this.unescapeString(puzzleData.initialPuzzle), - solutions: Object.fromEntries( - Object.entries(puzzleData.solutions).map(([key, value]) => [ - this.unescapeString(key), - this.unescapeString(value) - ]) - ) - }; - - return true; - - } catch (error) { - console.error('Error fetching puzzle:', error); - return false; - } - } - - unescapeString(str) { - if (typeof str !== 'string') return str; - return str.replace(/\\"/g, '"') - .replace(/\\'/g, "'") - .replace(/\\\\/g, "\\"); - } - - /*********************** - Date and URL management - ************************/ - - getNYCDate() { - // Create a date object - const date = new Date(); - - // Get the NY time options - const nyOptions = { - timeZone: 'America/New_York', - year: 'numeric', - month: '2-digit', - day: '2-digit' - }; - - // Format date in NY timezone - const nyDateStr = date.toLocaleString('en-US', nyOptions); - - // Split into components and rearrange into YYYY-MM-DD - const [month, day, year] = nyDateStr.split('/'); - return `${year}-${month}-${day}`; - } - - formatNYCDate(date = this.getNYCDate()) { - // Split the YYYY-MM-DD format - const [year, month, day] = date.split('-'); - - // Create a Date object in the NYC timezone - // First create a date string with the format that ensures it's interpreted as NYC time - const nycDateString = `${year}-${month}-${day}T12:00:00-05:00`; // Noon in NYC to avoid DST issues - const dateObj = new Date(nycDateString); - - // Format it for display using the NYC timezone - return dateObj.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - timeZone: 'America/New_York' - }); - } - - /** - * Updates URL with the current puzzle date - * @param {string} puzzleDate - Date in YYYY-MM-DD format - */ - updateURL(puzzleDate) { - const url = new URL(window.location.href); - - // If we have an encoded puzzle, don't modify URL - if (url.searchParams.has('d')) { - return; - } - - // Get today's date in NYC time zone - const todayPuzzleDate = this.getNYCDate(); - - // Check if we're using path format or query parameter format - const isPathFormat = window.location.pathname.match(/\/puzzles\/\d{4}\/\d+\/\d+/); - - if (isPathFormat) { - // If using path format, keep using path format - if (puzzleDate !== todayPuzzleDate) { - // Format the date components for the path - const [year, month, day] = puzzleDate.split('-'); - const newPath = `/puzzles/${year}/${parseInt(month)}/${parseInt(day)}`; - window.history.pushState({}, '', newPath); - } else { - // If it's today's puzzle, go to home - window.history.pushState({}, '', '/'); - } - } else { - // Using query parameter format, stick with that - url.searchParams.delete('date'); - if (puzzleDate !== todayPuzzleDate) { - url.searchParams.set('date', puzzleDate); - } - window.history.pushState({}, '', url.toString()); - } - } - - /** - * Gets puzzle date from URL or defaults to current NYC date - * @returns {string} Puzzle date in YYYY-MM-DD format - */ - getPuzzleDateFromURL() { - // NEW: First check path for /puzzles/YYYY/M/D pattern - const pathMatch = window.location.pathname.match(/\/puzzles\/(\d{4})\/(\d{1,2})\/(\d{1,2})/); - if (pathMatch) { - const year = pathMatch[1]; - const month = pathMatch[2].padStart(2, '0'); - const day = pathMatch[3].padStart(2, '0'); - const dateStr = `${year}-${month}-${day}`; - - if (this.isValidPuzzleDate(dateStr)) { - return dateStr; - } - } - - // UNCHANGED: Your existing query parameter logic stays exactly the same - const urlParams = new URLSearchParams(window.location.search); - - if (urlParams.has('d')) { - const decoded = PuzzleEncoder.decodePuzzle(urlParams.get('d')); - return decoded?.puzzleDate || this.getNYCDate(); - } - - const dateParam = urlParams.get('date'); - if (dateParam && this.isValidPuzzleDate(dateParam)) { - return dateParam; - } - - return this.getNYCDate(); - } - - /** - * Validates a puzzle date string - * @param {string} dateStr - Date string to validate - * @returns {boolean} Whether the date is valid - */ - isValidPuzzleDate(dateStr) { - // Check format (YYYY-MM-DD) - if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { - return false; - } - - const date = new Date(dateStr); - if (isNaN(date.getTime())) { - return false; - } - - // On dev servers, allow any valid date - if (this.isDevelopmentEnvironment()) { - return true; - } - - // On production, don't allow future dates - const nycDate = new Date(this.getNYCDate()); - return date <= nycDate; - } - - - /********** - Game logic - ***********/ - - /** - * Finds all active clues in the current puzzle state - * @param {string} puzzleText - Current puzzle text - * @returns {Array<{start: number, end: number, text: string, expression: string}>} - */ - findActiveClues(puzzleText) { - const activeClues = []; - - function findClues(str, startOffset = 0) { - let i = 0; - while (i < str.length) { - if (str[i] === '[') { - const startIndex = i; - let bracketCount = 1; - let hasNestedBrackets = false; - i++; - - let innerContent = ''; - while (i < str.length && bracketCount > 0) { - if (str[i] === '[') { - bracketCount++; - hasNestedBrackets = true; - } else if (str[i] === ']') { - bracketCount--; - } - innerContent += str[i]; - i++; - } - - innerContent = innerContent.slice(0, -1); - - if (!hasNestedBrackets) { - // Clean expression text of HTML markup - const cleanExpression = innerContent.replace(/<\/?[^>]+(>|$)/g, ''); - activeClues.push({ - start: startOffset + startIndex, - end: startOffset + i, - text: str.substring(startIndex, i), - expression: cleanExpression - }); - } - - if (hasNestedBrackets) { - findClues(innerContent, startOffset + startIndex + 1); - } - } else { - i++; - } - } - } - - const cleanText = puzzleText.replace(/<\/?span[^>]*(>|$)/g, ''); - findClues(cleanText); - return activeClues; - } - - /** - * Finds available expressions that can be solved - * @param {string} text - Current puzzle text - * @returns {Array<{expression: string, startIndex: number, endIndex: number}>} - */ - findAvailableExpressions(text) { - const cleanText = text.replace(/<\/?span[^>]*(>|$)/g, ''); - const results = []; - const regex = /\[([^\[\]]+?)\]/g; - const matchedPositions = new Set(); - let match; - - while ((match = regex.exec(cleanText)) !== null) { - const startIdx = match.index; - const endIdx = startIdx + match[0].length; - - if (!matchedPositions.has(startIdx)) { - matchedPositions.add(startIdx); - results.push({ - expression: match[1], - startIndex: startIdx, - endIndex: endIdx - }); - } - } - - return results; - } - - /** - * Generates new state after solving a clue - * @param {Object} clue - Clue object - * @param {string} solution - Solution text - * @returns {string} New display state - */ - generateSolvedClueState(clue, solution) { - const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); - const escapedSolution = solution - .replace(/"/g, '"') - .replace(/'/g, '''); - - return ( - cleanState.slice(0, clue.startIndex) + - `<mark class="solved">${escapedSolution}</mark>` + - cleanState.slice(clue.endIndex) - ); - } - - /** - * Handles user input for solving expressions - * @param {string} input - User input string - */ - handleInput(input) { - const normalizedInput = this.normalizeInput(input); - if (!normalizedInput) { - return; - } - - const match = this.findMatchingExpression(normalizedInput); - if (!match) { - return; - } - - this.solveExpression(match); - } - - /** - * Normalizes and validates user input - * @param {string} input - Raw user input - * @returns {string|null} Normalized input or null if invalid - */ - normalizeInput(input) { - if (!input?.trim()) { - return null; - } - return input.trim().toLowerCase(); - } - - /** - * Handles submission in submit mode - */ - handleSubmission() { - const input = this.isMobile ? this.customInputValue : this.answerInput.value; - if (!input || !input.trim()) return; - - const normalizedInput = this.normalizeInput(input); - const match = this.findMatchingExpression(normalizedInput); - - if (match) { - this.solveExpression(match); - - // Check if puzzle is complete after solving - if (this.isPuzzleComplete()) { - this.renderCompletedPuzzle( - this.root.querySelector('.puzzle-display'), - this.root.querySelector('.input-container'), - this.root.querySelector('.keystroke-stats') - ); - } - } else { - // Increment wrong guesses counter and show error message - this.updateGameState({ - wrongGuesses: (this.state.wrongGuesses || 0) + 1 - }); - - this.showMessage('Incorrect!', 'error'); - } - - // Clear input - if (this.isMobile) { - this.customInputValue = ''; - this.updateCustomInputDisplay(); - } else { - this.answerInput.value = ''; - this.answerInput.focus(); - } - } - - /** - * Finds an unsolved expression matching the input - * @param {string} normalizedInput - Normalized user input - * @returns {Object|null} Matching expression info or null if not found - */ - findMatchingExpression(normalizedInput) { - const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); - const availableExpressions = this.findAvailableExpressions(cleanState); - - for (const {expression, startIndex, endIndex} of availableExpressions) { - const solution = this.PUZZLE_DATA.solutions[expression]; - - if (solution?.toLowerCase() === normalizedInput && - !this.state.solvedExpressions.has(expression)) { - return { - expression, - solution, - startIndex, - endIndex - }; - } - } - - return null; - } - - /** - * Processes a correct solution and updates game state - * @param {Object} match - Expression match information - */ - solveExpression(match) { - const { expression, solution, startIndex, endIndex } = match; - - const isFirstSolve = this.state.solvedExpressions.size === 0; - - // Generate the new puzzle display state by replacing the solved expression - const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); - const escapedSolution = solution - .replace(/"/g, '"') - .replace(/'/g, '''); - - const newDisplayState = - cleanState.slice(0, startIndex) + - `<mark class="solved">${escapedSolution}</mark>` + - cleanState.slice(endIndex); - - // Add new expression at the beginning of the Set to maintain reverse chronological order - const updatedExpressions = new Set([expression, ...Array.from(this.state.solvedExpressions)]); - - // turn off tutorial prompt - this.turnOffTutorialPulse(); - - // Update game state with the new display and mark the expression as solved - this.updateGameState({ - displayState: newDisplayState, - solvedExpressions: updatedExpressions - }); - - // Show a success message (auto-clears later) - this.showMessage('Correct!', 'success'); - - // Clear the input based on device type: - if (this.isMobile) { - this.customInputValue = ''; - if (this.customInputEl) { - this.updateCustomInputDisplay(); - } - } else { - if (this.answerInput) { - this.answerInput.value = ''; - } - } - - // Re-render the puzzle display - requestAnimationFrame(() => { - this.render(); - }); - } - - /** - * Processes a solution reveal for megapeek - * @param {Object} match - Expression match information - */ - processSolutionReveal(expression, solution) { - // Clean the display state first - const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); - const availableExpressions = this.findAvailableExpressions(cleanState); - - for (const {startIndex, endIndex} of availableExpressions) { - const currentExpression = this.unescapeString( - cleanState.slice(startIndex + 1, endIndex - 1).trim() - ); - - if (this.unescapeString(expression) === currentExpression) { - const escapedSolution = solution - .replace(/"/g, '"') - .replace(/'/g, '''); - - const newDisplayState = - cleanState.slice(0, startIndex) + - `<mark class="solved">${escapedSolution}</mark>` + - cleanState.slice(endIndex); - - return newDisplayState; - } - } - return null; - } - - /************ - Hint system - *************/ - - /** - * Toggles hint mode for an expression between peek (show length) and mega peek (reveal answer) - * @param {string} expression - The expression to toggle hints for - */ - toggleHintMode(expression) { - // Early validation - if (!expression || this.state.megaPeekedClues.has(expression)) { - return; - } - - // Handle mega peek (second click) - if (this.state.hintModeClues.has(expression)) { - this.handleMegaPeek(expression); - return; - } - - // Handle first peek - this.handleFirstPeek(expression); - } - - /** - * Handles the first peek at an expression (shows length) - * @param {string} expression - The expression to peek at - */ - handleFirstPeek(expression) { - // Add confirmation dialog for regular peek - if (!confirm('You sure you want to peek at the first letter of this answer?')) { - return; - } - - this.updateGameState({ - hintModeClues: new Set([...this.state.hintModeClues, expression]), - peekedClues: new Set([...this.state.peekedClues, expression]) - }); - - this.render(); - } - - /** - * Handles the mega peek action (reveals answer) - * @param {string} expression - The expression to reveal - */ - handleMegaPeek(expression) { - if (!confirm('You sure you want to reveal this answer?')) { - return; - } - - const solution = this.PUZZLE_DATA.solutions[expression]; - if (!solution) { - console.error('No solution found for expression:', expression); - return; - } - - const newDisplayState = this.processSolutionReveal(expression, solution); - if (!newDisplayState) { - console.error('Could not find expression in puzzle state'); - return; - } - - this.updateGameState({ - displayState: newDisplayState, - totalKeystrokes: this.state.totalKeystrokes + solution.length, - megaPeekedClues: new Set([...this.state.megaPeekedClues, expression]), - solvedExpressions: new Set([expression, ...Array.from(this.state.solvedExpressions)]), - hintModeClues: new Set([...this.state.hintModeClues].filter(e => e !== expression)) - }); - - this.render(); - } - - /*************** - Input Handling - ****************/ - - /** - * Conservatively detects if the current device is a tablet (iPad or Android) - * @returns {boolean} True if the device is confidently identified as a tablet - */ - isTablet() { - // IPAD DETECTION - const isLegacyIPad = /iPad/i.test(navigator.userAgent); - const isModernIPad = !(/iPhone|iPod/.test(navigator.userAgent)) && - navigator.platform === 'MacIntel' && - navigator.maxTouchPoints > 1 && - !window.MSStream; - - // Any iPad detection is sufficient - const isIPad = isLegacyIPad || isModernIPad; - - // ANDROID TABLET DETECTION - // Standard way: Android without "Mobile" in UA string typically means tablet - const isAndroidTablet = /Android/i.test(navigator.userAgent) && - !/Mobile/i.test(navigator.userAgent); - - // Alternative Android tablet signals - const hasExplicitTabletIdentifier = /Tablet|Tab/i.test(navigator.userAgent) || - /SM-T[0-9]{3}/i.test(navigator.userAgent); // Samsung Galaxy Tab model numbers - - // GENERIC TABLET SIZE CHECK (as backup, not primary signal) - // Most tablets have at least one dimension ≥ 768px - const hasTabletDimensions = Math.max( - screen.width, - screen.height, - window.innerWidth, - window.innerHeight - ) >= 768; - - // EXPLICIT PHONE DETECTION (to avoid false positives) - const isExplicitlyPhone = /iPhone|iPod|Android.*Mobile|Mobile.*Android|BlackBerry|IEMobile|Opera Mini|Windows Phone/i.test(navigator.userAgent); - - // Combined tablet detection: - return isIPad || ((isAndroidTablet || hasExplicitTabletIdentifier) && - hasTabletDimensions && - !isExplicitlyPhone); - } - - /** - * Sets up input handlers based on current mode - */ - setupInputHandlers() { - if (this.useCustomKeyboard) { - // Use custom keyboard for phones - this.setupMobileInput(); - } else { - // Use native keyboard for tablets and desktops - this.setupDesktopInput(); - } - } - - /** - * Sets up desktop input handlers - */ - setupDesktopInput() { - this.answerInput = this.root.querySelector('.answer-input'); - const submitButton = this.root.querySelector('.submit-answer'); - - if (!this.answerInput) { - console.error('Answer input element not found'); - return; - } - - if (this.inputMode === 'submit') { - // Submit mode - only handle input on submit button click - if (submitButton) { - submitButton.addEventListener('click', () => { - this.handleSubmission(); - }); - } - - // Handle Enter key press - this.answerInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - - // Apply visual feedback if submit button exists - if (submitButton) { - submitButton.style.transform = 'scale(0.95)'; - submitButton.style.backgroundColor = '#1e40af'; - - // Remove the styles after a short delay - setTimeout(() => { - submitButton.style.transform = ''; - submitButton.style.backgroundColor = ''; - }, 85); - } - - this.handleSubmission(); - } - }); - } else { - // Classic mode - auto-snap answers as typed - this.answerInput.addEventListener('input', (e) => { - this.handleInput(e.target.value); - }); - } - - this.answerInput.addEventListener('keydown', (e) => { - this.handleKeyDown(e); - }); - - this.answerInput.addEventListener('paste', (e) => { - e.preventDefault(); - }); - - this.answerInput.focus(); - } - - /** - * Sets up mobile input handlers - */ - setupMobileInput() { - this.customInputValue = this.customInputValue || ''; - this.customInputEl = this.root.querySelector('.custom-input'); - - if (!this.customInputEl) { - console.error('Custom input display element not found'); - return; - } - - // Clean up old placeholder if it exists - const oldPlaceholder = this.customInputEl.querySelector('.placeholder'); - if (oldPlaceholder) { - oldPlaceholder.remove(); - } - - // Create fresh placeholder - const placeholderEl = document.createElement('span'); - placeholderEl.className = 'placeholder'; - placeholderEl.style.color = '#9ca3af'; - placeholderEl.textContent = this.inputMode === 'classic' ? 'start typing answers...' : 'type any answer...'; - this.customInputEl.appendChild(placeholderEl); - - // Set up the mobile submit button (if in submit mode) - if (this.inputMode === 'submit') { - const mobileSubmitButton = this.root.querySelector('.mobile-submit-button'); - if (mobileSubmitButton) { - mobileSubmitButton.addEventListener('click', () => { - this.handleSubmission(); - }); - } - } - - // Clean up any existing listeners first - const oldKeyboardKeys = this.root.querySelectorAll('.keyboard-key'); - oldKeyboardKeys.forEach(keyEl => { - const newKeyEl = keyEl.cloneNode(true); - keyEl.parentNode.replaceChild(newKeyEl, keyEl); - }); - - // Attach fresh click listeners to keyboard keys - const keyboardKeys = this.root.querySelectorAll('.keyboard-key'); - keyboardKeys.forEach(keyEl => { - keyEl.addEventListener('click', () => { - const key = keyEl.getAttribute('data-key'); - - if (key === 'backspace') { - this.customInputValue = this.customInputValue.slice(0, -1); - } else if (key === '123' || key === 'ABC') { - this.isAltKeyboard = !this.isAltKeyboard; - // Re-render the keyboard - const keyboardContainer = this.root.querySelector('.custom-keyboard'); - if (keyboardContainer) { - keyboardContainer.innerHTML = this.generateKeyboardButtonsHTML(); - } - // Re-attach event listeners while preserving input - this.setupMobileInput(); - return; - } else { - this.customInputValue += key; - - this.updateGameState({ totalKeystrokes: this.state.totalKeystrokes + 1 }); - - } - - this.updateCustomInputDisplay(); - - // In classic mode, check for solution match after each key - if (this.inputMode === 'classic') { - this.handleInput(this.customInputValue); - } - }); - - // Prevent dragging/swiping on the key - keyEl.addEventListener('touchmove', (e) => { - e.preventDefault(); - }, { passive: false }); - }); - - // Enable :active states on iOS - document.addEventListener('touchstart', () => {}, false); - - // Update display to show current input value - this.updateCustomInputDisplay(); - } - - updateCustomInputDisplay() { - if (!this.customInputEl) return; - - // Find or create placeholder - let placeholderEl = this.customInputEl.querySelector('.placeholder'); - if (!placeholderEl) { - placeholderEl = document.createElement('span'); - placeholderEl.className = 'placeholder'; - placeholderEl.style.color = '#9ca3af'; - placeholderEl.textContent = 'type any answer...'; - } - - // Clear the input - this.customInputEl.innerHTML = ''; - - // Show either placeholder or input value - if (this.customInputValue.length > 0) { - this.customInputEl.textContent = this.customInputValue; - } else { - this.customInputEl.appendChild(placeholderEl); - } - } - - /** - * Handles keydown events for keystroke tracking - * @param {KeyboardEvent} event - The keyboard event - */ - handleKeyDown(event) { - // turn off tutorial pulse - // Only count meaningful keystrokes (not control keys) - if (this.isCountableKeystroke(event)) { - this.updateGameState({ - totalKeystrokes: this.state.totalKeystrokes + 1 - }); - - } - } - - /** - * Determines if a keystroke should be counted - * @param {KeyboardEvent} event - The keyboard event - * @returns {boolean} Whether the keystroke should be counted - */ - isCountableKeystroke(event) { - // Only count single printable characters - return event.key.length === 1 && - /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/? ]$/.test(event.key); - } - - /*********** - Navigation - ************/ - - /** - * Creates and attaches the date picker functionality - */ - setupDatePicker() { - // Find the date element to attach the picker to - const dateElement = this.root.querySelector('.puzzle-date'); - if (!dateElement) return; - - // Remove any existing date picker containers to prevent duplicates - const existingContainers = this.root.querySelectorAll('.date-picker-container'); - existingContainers.forEach(container => container.remove()); - - // Clean up any existing event listeners by cloning the element - const dateParent = dateElement.parentNode; - const newDateElement = dateElement.cloneNode(true); - dateParent.replaceChild(newDateElement, dateElement); - - // Make it look clickable - newDateElement.style.cursor = 'pointer'; - newDateElement.title = 'Tap to open puzzle calendar'; - - // Add visual indication for mobile users - const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); - if (isMobile) { - // Add a calendar icon or more visible indicator for mobile - dateParent.style.position = 'relative'; - - // Enhance tap target size for mobile - newDateElement.style.padding = '8px 4px'; - newDateElement.style.position = 'relative'; - newDateElement.style.zIndex = '1'; - } - - // Create a container for the date picker (initially hidden) - const datePickerContainer = document.createElement('div'); - datePickerContainer.className = 'date-picker-container'; - datePickerContainer.style.display = 'none'; - datePickerContainer.style.position = 'absolute'; - datePickerContainer.style.top = '100%'; - datePickerContainer.style.left = '50%'; - datePickerContainer.style.transform = 'translateX(-50%)'; - datePickerContainer.style.zIndex = '1500'; // Increased z-index to ensure visibility - datePickerContainer.style.backgroundColor = 'white'; - datePickerContainer.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)'; - datePickerContainer.style.borderRadius = '0.5rem'; - datePickerContainer.style.padding = '1rem'; - datePickerContainer.style.marginTop = '0.5rem'; - datePickerContainer.style.maxWidth = isMobile ? '300px' : '340px'; - datePickerContainer.style.width = isMobile ? '90vw' : '100%'; - datePickerContainer.style.boxSizing = 'border-box'; // Ensure padding is included in width - - // Insert the container after the date element - dateParent.style.position = 'relative'; - dateParent.style.zIndex = '100'; // Ensure parent has reasonable z-index - dateParent.appendChild(datePickerContainer); - - // Toggle function to open/close the date picker - const toggleDatePicker = (e) => { - // Prevent default to avoid any browser handling - e.preventDefault(); - e.stopPropagation(); - - // Add log for debugging - console.log('Date picker toggle clicked', { - containerDisplay: datePickerContainer.style.display, - isCompletionState: this.isPuzzleComplete() - }); - - if (datePickerContainer.style.display === 'none') { - this.renderDatePicker(datePickerContainer); - datePickerContainer.style.display = 'block'; - - // Create a close handler that works for both mouse and touch - const closeCalendar = (event) => { - // Don't close if clicked on the date element or within the picker - if (!datePickerContainer.contains(event.target) && event.target !== newDateElement) { - datePickerContainer.style.display = 'none'; - document.removeEventListener('click', closeCalendar); - document.removeEventListener('touchend', closeCalendar); - } - }; - - // Add both mouse and touch event listeners to handle closing - document.addEventListener('click', closeCalendar); - document.addEventListener('touchend', closeCalendar); - } else { - datePickerContainer.style.display = 'none'; - } - }; - - // Add event listeners for both click and touch events with preventTouchFocus fix - const preventTouchFocus = (e) => { - e.preventDefault(); // Prevent focus/highlight issues on some mobile browsers - toggleDatePicker(e); - }; - - newDateElement.addEventListener('click', toggleDatePicker); - newDateElement.addEventListener('touchend', preventTouchFocus); - - // Add visible debug helper in development environments - if (this.isDevelopmentEnvironment()) { - newDateElement.setAttribute('data-debug', 'date-picker-trigger'); - } - } - - /** - * Renders the date picker with available puzzles - * @param {HTMLElement} container - The container to render the date picker in - */ - async renderDatePicker(container) { - // Show loading state - container.innerHTML = '<div class="loading-calendar">Loading calendar...</div>'; - - try { - // Fetch available puzzles - const availableDates = await this.fetchAvailablePuzzleDates(); - if (!availableDates || availableDates.length === 0) { - container.innerHTML = '<div class="error">No puzzles available</div>'; - return; - } - - // Get completion status for all puzzles - const puzzleStatuses = this.getPuzzleCompletionStatuses(availableDates); - - // Determine the current month and year (in NYC timezone) - const currentDateNYC = new Date(this.getNYCDate()); - let currentViewMonth = currentDateNYC.getMonth(); - let currentViewYear = currentDateNYC.getFullYear(); - - // Render initial calendar - this.renderMonthCalendar(container, currentViewMonth, currentViewYear, availableDates, puzzleStatuses); - - } catch (error) { - console.error('Error rendering date picker:', error); - container.innerHTML = '<div class="error">Failed to load calendar</div>'; - } - } - - /** - * Renders a specific month's calendar - * @param {HTMLElement} container - The container element - * @param {number} month - Month to render (0-11) - * @param {number} year - Year to render - * @param {string[]} availableDates - Array of available puzzle dates - * @param {Object} puzzleStatuses - Map of dates to completion status - */ - renderMonthCalendar(container, month, year, availableDates, puzzleStatuses) { - // Calculate NYC date strings correctly - const getConsistentDateStr = (year, month, day) => { - // This ensures we're constructing dates consistently as YYYY-MM-DD - return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - }; - - // Get month info - using local date objects for display purposes only - const monthStart = new Date(year, month, 1); - const monthName = monthStart.toLocaleString('en-US', { month: 'long', year: 'numeric' }); - - // Get days in month - const daysInMonth = new Date(year, month + 1, 0).getDate(); - - // Get the day of week the month starts on (0 = Sunday, 6 = Saturday) - const startDay = monthStart.getDay(); - - // Get today's date in NYC - const todayNYC = this.getNYCDate(); - - // Create calendar HTML - let calendarHTML = ` - <div class="date-picker-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> - <button class="month-nav prev" style="background: none; border: none; cursor: pointer; font-size: 1.2rem;">‹</button> - <h3 style="margin: 0; text-align: center; font-size: 1.2rem;">${monthName}</h3> - <button class="month-nav next" style="background: none; border: none; cursor: pointer; font-size: 1.2rem;">›</button> - </div> - - <div class="weekdays" style="display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; font-weight: bold; margin-bottom: 0.5rem;"> - <div>Su</div> - <div>Mo</div> - <div>Tu</div> - <div>We</div> - <div>Th</div> - <div>Fr</div> - <div>Sa</div> - </div> - - <div class="calendar-grid" style="display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; width: 100%;"> - `; - - // Add empty cells for days before the month starts - for (let i = 0; i < startDay; i++) { - calendarHTML += `<div class="empty-day"></div>`; - } - - // Add cells for each day in the month - for (let day = 1; day <= daysInMonth; day++) { - // Format date string consistently - const dateStr = getConsistentDateStr(year, month, day); - - // Check if this date has a puzzle available - const isPuzzleAvailable = availableDates.includes(dateStr); - - // Get puzzle status for this date - const puzzleStatus = puzzleStatuses[dateStr] || 'unsolved'; - - // Determine if this is today's date - const isToday = dateStr === todayNYC; - - // Determine if this is the current puzzle date - const isCurrentPuzzle = dateStr === this.currentPuzzleDate; - - // Determine future dates (not yet available) - const isFutureDate = dateStr > todayNYC && !this.isDevelopmentEnvironment(); - - // Status-specific styles - let statusStyle = ''; - if (isPuzzleAvailable) { - switch (puzzleStatus) { - case 'complete': - statusStyle = 'background-color: #c6f6d5; color: #22543d; border: 1px solid #9ae6b4;'; - break; - case 'started': - statusStyle = 'background-color: #fefcbf; color: #744210; border: 1px solid #f6e05e;'; - break; - case 'unsolved': - statusStyle = 'background-color: #e9f5f9; color: #2c5282; border: 1px solid #bee3f8;'; - break; - default: - statusStyle = 'background-color: #fff; color: #1a202c; border: 1px solid #e2e8f0;'; - } - } else { - // No puzzle available - statusStyle = 'background-color: #f9f9f9; color: #a0aec0; border: 1px solid #edf2f7;'; - } - - // Today and current puzzle styles - const todayStyle = isToday ? 'font-weight: bold; border: 2px solid #4a5568;' : ''; - const currentStyle = isCurrentPuzzle ? 'box-shadow: 0 0 0 2px #4299e1;' : ''; - - // Cell classes and attributes - const cellClass = isPuzzleAvailable ? 'date-cell has-puzzle' : 'date-cell no-puzzle'; - const disabledAttr = (isFutureDate || !isPuzzleAvailable) ? 'disabled="true"' : ''; - const dataAttr = isPuzzleAvailable ? `data-date="${dateStr}" data-status="${puzzleStatus}"` : ''; - - // Add cell to calendar - calendarHTML += ` - <div - class="${cellClass}" - ${dataAttr} - ${disabledAttr} - style=" - padding: 0.4rem 0; - border-radius: 0.375rem; - text-align: center; - ${isFutureDate || !isPuzzleAvailable ? 'cursor: default; opacity: 0.5;' : 'cursor: pointer;'} - ${statusStyle} - ${todayStyle} - ${currentStyle} - " - title="${this.formatDateMonthDay(dateStr)}${isPuzzleAvailable ? ` (${puzzleStatus})` : ' (no puzzle)'}" - > - ${day} - </div> - `; - } - - calendarHTML += ` - </div> - <div class="calendar-footer" style="margin-top: 1rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1rem;"> - <span style="display: flex; align-items: center;"> - <span style="display: inline-block; width: 12px; height: 12px; margin-right: 4px; background-color: #c6f6d5; border: 1px solid #9ae6b4; border-radius: 2px;"></span> Complete - </span> - <span style="display: flex; align-items: center;"> - <span style="display: inline-block; width: 12px; height: 12px; margin-right: 4px; background-color: #fefcbf; border: 1px solid #f6e05e; border-radius: 2px;"></span> Started - </span> - <span style="display: flex; align-items: center;"> - <span style="display: inline-block; width: 12px; height: 12px; margin-right: 4px; background-color: #e9f5f9; border: 1px solid #bee3f8; border-radius: 2px;"></span> Available - </span> - </div> - `; - - // Set the calendar HTML - container.innerHTML = calendarHTML; - - // Add navigation handlers - this.setupMonthNavigation(container, month, year, availableDates, puzzleStatuses); - - // Add date selection handlers - this.setupDateSelectionHandlers(container); - } - - /** - * Format date as "Month Day" (e.g. "March 15") - * @param {string} dateStr - Date string in YYYY-MM-DD format - * @returns {string} Formatted date - */ - formatDateMonthDay(dateStr) { - // Parse the YYYY-MM-DD string directly - const [year, month, day] = dateStr.split('-'); - - // Create a date string that displays as intended - return `${new Date(Number(year), Number(month) - 1, Number(day)).toLocaleDateString('en-US', { - month: 'long', - day: 'numeric' - })}`; - } - - /** - * Sets up month navigation buttons - * @param {HTMLElement} container - Container element - * @param {number} currentMonth - Current month view (0-11) - * @param {number} currentYear - Current year view - * @param {string[]} availableDates - Available puzzle dates - * @param {Object} puzzleStatuses - Puzzle completion statuses - */ - setupMonthNavigation(container, currentMonth, currentYear, availableDates, puzzleStatuses) { - const prevButton = container.querySelector('.month-nav.prev'); - const nextButton = container.querySelector('.month-nav.next'); - - // Make buttons more touch-friendly - const enhanceButton = (button) => { - button.style.width = '36px'; - button.style.height = '36px'; - button.style.fontSize = '1.5rem'; - button.style.cursor = 'pointer'; - button.style.userSelect = 'none'; - button.style.touchAction = 'manipulation'; - }; - - enhanceButton(prevButton); - enhanceButton(nextButton); - - // Handler for previous month - Works with both mouse and touch - const handlePrevMonth = (e) => { - e.preventDefault(); - e.stopPropagation(); - - let newMonth = currentMonth - 1; - let newYear = currentYear; - - if (newMonth < 0) { - newMonth = 11; - newYear--; - } - - this.renderMonthCalendar(container, newMonth, newYear, availableDates, puzzleStatuses); - }; - - // Handler for next month - Works with both mouse and touch - const handleNextMonth = (e) => { - e.preventDefault(); - e.stopPropagation(); - - let newMonth = currentMonth + 1; - let newYear = currentYear; - - if (newMonth > 11) { - newMonth = 0; - newYear++; - } - - this.renderMonthCalendar(container, newMonth, newYear, availableDates, puzzleStatuses); - }; - - // Add event listeners for both mouse and touch events - prevButton.addEventListener('click', handlePrevMonth); - prevButton.addEventListener('touchend', handlePrevMonth); - - nextButton.addEventListener('click', handleNextMonth); - nextButton.addEventListener('touchend', handleNextMonth); - } - - /** - * Sets up event handlers for date selection - * @param {HTMLElement} container - Calendar container - */ - setupDateSelectionHandlers(container) { - // Find all date cells with puzzles - const dateCells = container.querySelectorAll('.date-cell.has-puzzle:not([disabled])'); - - dateCells.forEach(cell => { - // Create a visual feedback effect for touch - cell.style.transition = 'transform 0.1s ease, opacity 0.1s ease'; - - // Enhanced tap/click area - cell.style.margin = '1px'; - cell.style.position = 'relative'; - - // Handler function that works for both click and touch - const handleDateSelection = async (e) => { - e.preventDefault(); - e.stopPropagation(); // Prevent closing the calendar immediately - - // Visual feedback - cell.style.transform = 'scale(0.95)'; - cell.style.opacity = '0.8'; - - // Reset visual feedback after a short delay - setTimeout(() => { - cell.style.transform = 'scale(1)'; - cell.style.opacity = '1'; - }, 150); - - const targetDate = cell.dataset.date; - if (!targetDate || targetDate === this.currentPuzzleDate) { - // Already on this puzzle or invalid date, just close the picker - container.style.display = 'none'; - return; - } - - // Navigate to selected puzzle - container.style.display = 'none'; - - await this.navigateToPuzzle(targetDate); - }; - - // Add both mouse and touch event listeners - cell.addEventListener('click', handleDateSelection); - cell.addEventListener('touchend', handleDateSelection); - }); - } - - /** - * Fetches all available puzzle dates from the API - * @returns {Promise<string[]>} Array of available dates in YYYY-MM-DD format - */ - async fetchAvailablePuzzleDates() { - try { - const response = await fetch(this.API_ENDPOINT, { - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' - } - }); - - if (!response.ok) { - throw new Error('Failed to fetch available puzzles'); - } - - const puzzles = await response.json(); - - // Filter out future puzzles if not in dev environment - const today = this.getNYCDate(); - const isDevEnvironment = this.isDevelopmentEnvironment(); - - return puzzles - .map(puzzle => typeof puzzle === 'string' ? puzzle : (puzzle.puzzleDate || puzzle)) - .filter(date => isDevEnvironment || date <= today) - .sort(); - - } catch (error) { - console.error('Error fetching available puzzles:', error); - return []; - } - } - - /** - * Scans localStorage to determine completion status for all puzzles - * @param {string[]} dates - Array of puzzle dates to check - * @returns {Object} Map of dates to completion status - */ - getPuzzleCompletionStatuses(dates) { - const statusMap = {}; - - dates.forEach(date => { - const storageKey = `bracketPuzzle_${date}`; - const savedData = localStorage.getItem(storageKey); - - if (!savedData) { - // No saved data - puzzle exists but not started - statusMap[date] = 'unsolved'; - } else { - try { - const puzzleData = JSON.parse(savedData); - - // Check if puzzle is complete - if (puzzleData.isComplete || !puzzleData.displayState.includes('[')) { - statusMap[date] = 'complete'; - } else if (this.isPuzzleStarted(puzzleData)) { - statusMap[date] = 'started'; - } else { - statusMap[date] = 'unsolved'; - } - } catch (e) { - console.error('Error parsing saved puzzle data:', e); - statusMap[date] = 'unsolved'; - } - } - }); - - return statusMap; - } - - /** - * Checks for available puzzles before and after current date - * @param {string} currentDate - Current puzzle date in YYYY-MM-DD format - */ - async checkAdjacentPuzzles(currentDate) { - try { - // Get current NYC date for availability check - const nycDate = this.getNYCDate(); - const isDevEnvironment = this.isDevelopmentEnvironment(); - - // 1. Fetch available puzzles - const response = await fetch(this.API_ENDPOINT, { - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' - } - }); - - if (!response.ok) { - throw new Error('Unable to fetch puzzle list'); - } - - const puzzles = await response.json(); - if (!puzzles?.length) { - console.log('No puzzles available'); - return this.updateAdjacentPuzzles(null, null); - } - - // 2. Extract and normalize dates - const puzzleDates = puzzles - .map(puzzle => puzzle.puzzleDate || puzzle.date) - .filter(Boolean) - .sort(); // Sort chronologically - - // 3. Find current puzzle's position - const currentIndex = puzzleDates.indexOf(currentDate); - if (currentIndex === -1) { - console.warn('Current puzzle not found in available puzzles'); - return this.updateAdjacentPuzzles(null, null); - } - - // 4. Determine previous puzzle (always available) - const previousPuzzle = currentIndex > 0 ? puzzleDates[currentIndex - 1] : null; - - // 5. Determine next puzzle (with availability check) - let nextPuzzle = null; - if (currentIndex < puzzleDates.length - 1) { - const potentialNext = puzzleDates[currentIndex + 1]; - // Allow future puzzles in dev, restrict in production - if (isDevEnvironment || potentialNext <= nycDate) { - nextPuzzle = potentialNext; - } - } - - // 6. Update navigation state - this.updateAdjacentPuzzles(previousPuzzle, nextPuzzle); - - } catch (error) { - console.error('Error checking adjacent puzzles:', error); - // Fail gracefully - disable navigation if we can't determine availability - this.updateAdjacentPuzzles(null, null); - } - } - - /** - * Updates navigation state and UI for adjacent puzzles - * @param {string|null} previous - Previous puzzle date - * @param {string|null} next - Next puzzle date - */ - updateAdjacentPuzzles(previous, next) { - // Update state - this.adjacentPuzzleDates = { previous, next }; - - // Update UI - const prevButton = this.root.querySelector('.nav-button.prev'); - const nextButton = this.root.querySelector('.nav-button.next'); - - if (prevButton) { - prevButton.disabled = !previous; - prevButton.style.opacity = previous ? '1' : '0.3'; - prevButton.style.cursor = previous ? 'pointer' : 'default'; - } - - if (nextButton) { - nextButton.disabled = !next; - nextButton.style.opacity = next ? '1' : '0.3'; - nextButton.style.cursor = next ? 'pointer' : 'default'; - } - } - - /** - * Improved navigateToPuzzle method to handle input mode properly - * @param {string} targetDate - Date to navigate to - */ - async navigateToPuzzle(targetDate) { - try { - console.log('Starting navigation to date:', targetDate); - this.setNavigationLoading(true); - - // Store the previous date before changing - const previousDate = this.currentPuzzleDate; - - // Store the current preferred input mode (from localStorage) - const preferredInputMode = this.getInputMode(); - - // Reset mobile input state before navigation - if (this.isMobile) { - this.customInputValue = ''; - this.customInputEl = null; - } - - // Fetch and process new puzzle data - await this.fetchPuzzleForDate(targetDate); - - if (!this.PUZZLE_DATA) { - throw new Error(`Failed to fetch puzzle for ${targetDate}`); - } - - // Important: Update current puzzle date AFTER successful fetch but BEFORE loading state - this.currentPuzzleDate = targetDate; - this.updateURL(targetDate); - - // Check for available adjacent puzzles - await this.checkAdjacentPuzzles(targetDate); - - // Reset state to prevent leakage - this.state = { - displayState: '', - solvedExpressions: new Set(), - message: '', - messageType: null, - totalKeystrokes: 0, - minimumKeystrokes: 0, - activeClues: [], - hintModeClues: new Set(), - peekedClues: new Set(), - megaPeekedClues: new Set(), - wrongGuesses: 0, - helpVisible: false, - previousDisplay: null - }; - - // Try to load saved state for this puzzle with explicit date parameter - const savedState = await this.loadSavedState(targetDate); - - // Initialize game state - if (savedState && savedState.puzzleDate === targetDate) { // Extra validation - console.log(`Loading saved state for ${targetDate}`); - - // Check if the saved state has an input mode - const savedInputMode = savedState.inputMode; - - // If the puzzle has been started (has progress), respect its saved mode - // Otherwise use the preferred mode - const hasPuzzleProgress = this.isPuzzleStarted(savedState); - - // Set the input mode based on progress status - if (hasPuzzleProgress && savedInputMode) { - this.inputMode = savedInputMode; - } else { - this.inputMode = preferredInputMode; - } - - // Update rankCalculator with the correct input mode - this.rankCalculator.inputMode = this.inputMode; - - this.updateGameState({ - displayState: savedState.displayState, - solvedExpressions: new Set(savedState.solvedExpressions || []), - totalKeystrokes: savedState.totalKeystrokes || 0, - minimumKeystrokes: savedState.minimumKeystrokes || this.calculateMinimumKeystrokes(), - activeClues: this.findActiveClues(savedState.displayState), - hintModeClues: new Set(savedState.hintModeClues || []), - peekedClues: new Set(savedState.peekedClues || []), - megaPeekedClues: new Set(savedState.megaPeekedClues || []), - wrongGuesses: savedState.wrongGuesses || 0, - helpVisible: savedState.helpVisible || false, - previousDisplay: savedState.previousDisplay || null - }); - } else { - console.log(`Initializing fresh state for ${targetDate}`); - - // For a fresh puzzle, use the preferred input mode - this.inputMode = preferredInputMode; - - // Update rankCalculator with the correct input mode - this.rankCalculator.inputMode = this.inputMode; - - this.updateGameState({ - displayState: this.PUZZLE_DATA.initialPuzzle, - solvedExpressions: new Set(), - message: '', - totalKeystrokes: 0, - minimumKeystrokes: this.calculateMinimumKeystrokes(), - activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle), - hintModeClues: new Set(), - peekedClues: new Set(), - megaPeekedClues: new Set(), - wrongGuesses: 0, - helpVisible: false, - previousDisplay: null - }); - } - - // Reset UI and reattach handlers - this.root.innerHTML = this.generateInitialHTML(); - this.setupInputHandlers(); - this.setupShareButtons(); - this.setupPuzzleDisplay(); - this.setupNavigationHandlers(); - - // Render with new state - this.render(); - - this.setupDatePicker(); - } catch (error) { - console.error('Navigation failed:', error); - this.showErrorMessage('Failed to load puzzle. Please try again.'); - } finally { - this.setNavigationLoading(false); - } - } - - /** - * Sets the loading state for navigation buttons - */ - setNavigationLoading(isLoading) { - const prevButton = this.root.querySelector('.nav-button.prev'); - const nextButton = this.root.querySelector('.nav-button.next'); - - if (prevButton) { - prevButton.disabled = isLoading || !this.adjacentPuzzleDates.previous; - prevButton.style.cursor = isLoading ? 'wait' : 'pointer'; - prevButton.style.opacity = prevButton.disabled ? '0.5' : '1'; - } - if (nextButton) { - nextButton.disabled = isLoading || !this.adjacentPuzzleDates.next; - nextButton.style.cursor = isLoading ? 'wait' : 'pointer'; - nextButton.style.opacity = nextButton.disabled ? '0.5' : '1'; - } - } - - /** - * Sets up navigation button event handlers - */ - setupNavigationHandlers() { - // First remove any existing handlers to prevent duplicates - this.cleanupEventListeners(); - - const prevButton = this.root.querySelector('.nav-button.prev'); - const nextButton = this.root.querySelector('.nav-button.next'); - - // Track if navigation is in progress to prevent double-clicks - let isNavigating = false; - - // Previous puzzle handler - if (prevButton) { - prevButton.addEventListener('click', async () => { - if (isNavigating || !this.adjacentPuzzleDates.previous) return; - - try { - isNavigating = true; - prevButton.style.cursor = 'wait'; - await this.navigateToPuzzle(this.adjacentPuzzleDates.previous); - } catch (error) { - console.error('Navigation failed:', error); - this.showErrorMessage('Failed to load previous puzzle. Please try again.'); - } finally { - isNavigating = false; - prevButton.style.cursor = this.adjacentPuzzleDates.previous ? 'pointer' : 'default'; - } - }); - } - - // Next puzzle handler - if (nextButton) { - nextButton.addEventListener('click', async () => { - if (isNavigating || !this.adjacentPuzzleDates.next) return; - - try { - isNavigating = true; - nextButton.style.cursor = 'wait'; - await this.navigateToPuzzle(this.adjacentPuzzleDates.next); - } catch (error) { - console.error('Navigation failed:', error); - this.showErrorMessage('Failed to load next puzzle. Please try again.'); - } finally { - isNavigating = false; - nextButton.style.cursor = this.adjacentPuzzleDates.next ? 'pointer' : 'default'; - } - }); - } - } - - /** - * Removes existing event listeners to prevent duplicates - */ - cleanupEventListeners() { - const prevButton = this.root.querySelector('.nav-button.prev'); - const nextButton = this.root.querySelector('.nav-button.next'); - - if (prevButton) { - const newPrevButton = prevButton.cloneNode(true); - prevButton.parentNode.replaceChild(newPrevButton, prevButton); - } - - if (nextButton) { - const newNextButton = nextButton.cloneNode(true); - nextButton.parentNode.replaceChild(newNextButton, nextButton); - } - } - - /************* - UI rendering - **************/ - - /* - * Main render method - updates all UI elements based on current state - */ - render() { - const elements = this.getDOMElements(); - - // Early return if required elements aren't found - if (!this.validateElements(elements)) { - console.error('Required DOM elements not found'); - return; - } - - this.renderPuzzleDisplay(elements.puzzleDisplay); - this.renderSolvedExpressions(elements.expressionsList); - this.renderMessage(elements.message); - - // Handle completion state - if (this.isPuzzleComplete()) { - this.renderCompletedPuzzle(elements.puzzleDisplay, elements.inputContainer, elements.keystrokeStats); - } else { - this.renderInProgressState(elements.keystrokeStats); - } - } - - /** - * Renders the main puzzle display - * @param {HTMLElement} displayElement - Puzzle display element - */ - renderPuzzleDisplay(displayElement) { - let highlightedState = this.applyActiveClueHighlights(this.state.displayState); - highlightedState = this.replaceUnderscoreSequences(highlightedState); - displayElement.innerHTML = highlightedState; - } - - replaceUnderscoreSequences(text) { - // Regular expression to match sequences of 2 or more underscores - return text.replace(/_{2,}/g, (match) => { - // Calculate width based on number of underscores (em units work well here) - const width = (match.length * 0.6) + 'em'; - return `<span class="blank-line" style="width: ${width};"></span>`; - }); - } - - /** - * Renders the solved expressions list - * @param {HTMLElement} listElement - Expressions list element - */ - renderSolvedExpressions(listElement) { - if (!listElement) return; - - const solvedHTML = Array.from(this.state.solvedExpressions) - .map(expression => this._generateSolvedExpressionItemHTML( - expression, - this.PUZZLE_DATA.solutions[expression] - )) - .join(''); - - listElement.innerHTML = solvedHTML; - } - - /** - * Renders the current message - * @param {HTMLElement} messageElement - Message element - */ - renderMessage(messageElement) { - if (!messageElement) return; - - if (this.state.message) { - messageElement.textContent = this.state.message; - messageElement.className = `message ${this.state.messageType || 'success'}`; - - // Apply error styling if needed - if (this.state.messageType === 'error') { - messageElement.style.cssText = ` - padding: 1rem; - margin: 1rem; - background-color: #fee2e2; - color: #ef4444; - border-radius: 0.375rem; - `; - } else { - messageElement.style.cssText = ''; // Reset styles for non-error messages - } - } else { - messageElement.textContent = ''; - messageElement.className = 'message'; - messageElement.style.cssText = ''; - } - } - - /** - * Renders the in-progress state - * @param {HTMLElement} statsElement - Stats element - */ - renderInProgressState(statsElement) { - // Hide stats during gameplay - statsElement.style.display = 'none'; - } - - renderCompletedPuzzle(puzzleDisplay, inputContainer, keystrokeStats) { - // Validate required elements - if (!puzzleDisplay || !inputContainer || !keystrokeStats) { - console.error('Missing required elements for completion rendering'); - return; - } - - // Hide the input container (for both desktop and mobile) - inputContainer.style.display = 'none'; - - // Set up the completed state - this.setupCompletionState(puzzleDisplay); - - // Use the puzzle display state; if the completion text isn't already present, prepend it - const hasCompletionText = this.state.displayState.includes(this.PUZZLE_DATA.completionText); - const finalDisplayText = hasCompletionText - ? this.state.displayState - : `<strong>${this.PUZZLE_DATA.completionText}</strong>\n\n${this.state.displayState}`; - puzzleDisplay.innerHTML = finalDisplayText; - - // Remove any existing completion message to avoid duplicates - const existingCompletionMessage = this.root.querySelector('.completion-message'); - if (existingCompletionMessage) { - existingCompletionMessage.remove(); - } - - // Find the puzzle-content container - const puzzleContent = this.root.querySelector('.puzzle-content'); - if (!puzzleContent) { - console.error('Could not find puzzle-content container'); - return; - } - - // Create the completion message - const completionMessageWrapper = document.createElement('div'); - completionMessageWrapper.className = 'completion-message'; - completionMessageWrapper.innerHTML = this.generateCompletionMessage(); - - // Insert after puzzle display - const insertAfterElement = puzzleDisplay; - if (insertAfterElement && insertAfterElement.nextSibling) { - puzzleContent.insertBefore(completionMessageWrapper, insertAfterElement.nextSibling); - } else { - puzzleContent.appendChild(completionMessageWrapper); - } - - // Show stats container and render completion stats UI - keystrokeStats.style.display = 'block'; - - // Calculate stats with wrong guesses - const efficiency = ((this.state.minimumKeystrokes / this.state.totalKeystrokes) * 100); - const stats = this.rankCalculator.getDetailedStats( - efficiency, - this.state.peekedClues.size, - this.state.megaPeekedClues.size, - this.state.wrongGuesses // Include wrong guesses - ); - - // Render stats - keystrokeStats.querySelector('.stats-content').innerHTML = ` - ${this.generateRankDisplay(stats)} - ${this.generateStatItems(stats)} - `; - - this.attachCompletionHandlers(keystrokeStats); - - // Scroll to top - setTimeout(() => { - window.scrollTo({ - top: 0, - behavior: 'smooth' - }); - this.setupDatePicker(); - }, 100); - } - - renderCompletionStats(keystrokeStats) { - const efficiency = ((this.state.minimumKeystrokes / this.state.totalKeystrokes) * 100); - const stats = this.rankCalculator.getDetailedStats( - efficiency, - this.state.peekedClues.size, - this.state.megaPeekedClues.size - ); - - keystrokeStats.style.display = 'block'; - keystrokeStats.querySelector('.stats-content').innerHTML = ` - ${this.generateRankDisplay(stats)} - ${this.generateStatItems(stats)} - `; - - this.attachCompletionHandlers(keystrokeStats); - } - - /** - * Apply highlighting to active clues with first letter peek - * @param {string} text - Current puzzle text - * @returns {string} HTML with highlights applied - */ - applyActiveClueHighlights(text) { - const cleanText = text.replace(/<\/?span[^>]*(>|$)/g, ''); - const activeClues = this.findActiveClues(cleanText); - - // Sort clues by start position in reverse order to process them from end to beginning - activeClues.sort((a, b) => b.start - a.start); - - let highlightedText = cleanText; - - activeClues.forEach(clue => { - const expressionText = clue.expression.trim(); - - // Create a precise slice by using the exact indices - const before = highlightedText.slice(0, clue.start); - let clueText = highlightedText.slice(clue.start, clue.end); - const after = highlightedText.slice(clue.end); - - // Handle different clue states - if (!clueText.includes('[') && !this.state.hintModeClues.has(expressionText)) { - // Already solved clue - leave as is - highlightedText = before + clueText + after; - } else { - // Active or hint mode clue - if (this.state.hintModeClues.has(expressionText)) { - const solution = this.PUZZLE_DATA.solutions[expressionText]; - // Show the first letter hint - if (solution) { - const firstLetter = solution.charAt(0).toUpperCase(); - // Use exact position for end bracket to avoid issues with special characters - const lastBracketPos = clueText.lastIndexOf(']'); - if (lastBracketPos !== -1) { - clueText = clueText.substring(0, lastBracketPos) + ` (${firstLetter})]`; - } - } - } - - // Use more robust string manipulation to ensure the entire expression is highlighted - highlightedText = before + `<span class="active-clue">${clueText}</span>` + after; - } - }); - - return highlightedText; - } - - /*************** - HTML generation - ****************/ - - generateLoadingHTML() { - return ` - <div class="loading"> - Loading today's puzzle... - </div> - `; - } - - /** - * Generates HTML for the input container based on current mode - * @returns {string} HTML markup - */ - generateInputContainerHTML() { - if (this.useCustomKeyboard) { - if (this.inputMode === 'submit') { - return ` - <div class="input-container"> - <div class="message"></div> - <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;"> - <div class="custom-input" style="flex-grow: 1; height: 44px; display: flex; align-items: center;"> - <span class="placeholder">type any answer...</span> - </div> - <button - class="mobile-submit-button" - style="height: 44px; background-color: #2563eb; color: white; padding: 0 1rem; border-radius: 6px; font-weight: 500; border: none;" - > - [enter] - </button> - </div> - <div class="custom-keyboard"> - ${this.generateKeyboardButtonsHTML()} - </div> - </div> - `; - } else { - // Classic mode (auto-snap) - return ` - <div class="input-container"> - <div class="message"></div> - <div class="custom-input"> - <span class="placeholder">start typing answers...</span> - </div> - <div class="custom-keyboard"> - ${this.generateKeyboardButtonsHTML()} - </div> - </div> - `; - } - } else { - // Desktop - if (this.inputMode === 'submit') { - return ` - <div class="input-container"> - <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem;"> - <input - type="text" - placeholder="type any answer..." - name="silly-joke" - class="answer-input" - autocomplete="off" - autocapitalize="off" - > - <button - class="submit-answer" - > - [enter] - </button> - </div> - <div class="message"></div> - </div> - `; - } else { - // Classic mode (auto-snap) - return ` - <div class="input-container"> - <input type="text" placeholder="Start typing answers..." name="silly-joke" class="answer-input" autocomplete="off" autocapitalize="off"> - <div class="message"></div> - </div> - `; - } - } - } - -// Modified generateKeyboardButtonsHTML method -// Modified generateKeyboardButtonsHTML method -generateKeyboardButtonsHTML() { - const mainLayout = [ - { keys: 'qwertyuiop', columns: 10 }, - { keys: 'asdfghjkl', columns: 10 }, - { keys: [ - { key: '123', display: '123', small: true }, - 'z', 'x', 'c', 'v', 'b', 'n', 'm', - { key: 'backspace', display: '\u{232B}', wide: true } - ], columns: 10 } - ]; - - const altLayout = [ - { keys: '1234567890', columns: 10 }, - { keys: '-/:;()$&@"', columns: 10 }, - { keys: [ - { key: 'ABC', display: 'ABC', small: true }, - '.', ',', '?', '!', "'", "=", "+", - { key: 'backspace', display: '\u{232B}', wide: true } - ], columns: 10 } - ]; - - const rows = this.isAltKeyboard ? altLayout : mainLayout; - let html = ``; - - rows.forEach((row, rowIndex) => { - // adding margin for middle row on default layout since it's only 9 keys vs, 10 - const leftPadding = (rowIndex === 1 && !this.isAltKeyboard) ? 'margin-left: 5%' : ''; - html += `<div class="keyboard-row" style="${leftPadding}">`; - - const keys = Array.isArray(row.keys) ? row.keys : row.keys.split(''); - keys.forEach(keyItem => { - if (typeof keyItem === 'string') { - html += this.generateKeyboardKeyHTML(keyItem); - } else { - html += this.generateSpecialKeyHTML(keyItem); - } - }); - - html += '</div>'; - }); - - html += '</div>'; - return html; -} - -generateSpecialKeyHTML(keyItem) { - const fontSize = keyItem.small ? '0.875rem' : '1.125rem'; - return ` - <button class="keyboard-key" data-key="${keyItem.key}" style=" - grid-column: ${keyItem.wide ? 'span 2' : 'span 1'}; - font-size: ${fontSize}; - "> - ${keyItem.display} - </button> - `; -} - - generateKeyboardKeyHTML(key) { - return ` - <button class="keyboard-key" data-key="${key}"> - ${key} - </button> - `; - } - - /** - * Generates initial HTML structure for the game - * @returns {string} HTML markup - */ - generateInitialHTML() { - const displayDate = this.currentPuzzleDate || this.getNYCDate(); - - return ` - <div class="puzzle-container"> - ${this.generateHeaderHTML(displayDate)} - <div class="puzzle-content"> - ${this.generatePuzzleDisplayHTML()} - ${this.generateInputContainerHTML()} - ${this.generateStatsContainerHTML()} - ${this.generateSolvedExpressionsHTML()} - </div> - </div> - `; - } - - generateHeaderHTML(displayDate) { - return ` - <div class="puzzle-header"> - <button class="info-button">!</button> - <h1>[Bracket Shitty]</h1> - <button class="help-button">?</button> - <div class="nav-container"> - <button class="nav-button prev" ${!this.isMobile && 'disabled'}>←</button> - <div class="puzzle-date">${this.formatNYCDate(displayDate)}</div> - <button class="nav-button next" ${!this.isMobile && 'disabled'}>→</button> - </div> - </div> - `; - } - - generatePuzzleDisplayHTML() { - return `<div class="puzzle-display"></div>`; - } - - generateStatsContainerHTML() { - return ` - <div class="keystroke-stats" style="display: none;"> - <div class="stats-content"></div> - <div class="share-buttons"> - <button class="share-button copy">Copy Results</button> - <button class="share-button sms">Share via Text</button> - <button class="share-button reset">Reset Puzzle</button> - </div> - <div class="share-message" style="display: none;">Results copied to clipboard!</div> - </div> - `; - } - - _generateSolvedExpressionItemHTML(expression, solution) { - return ` - <div class="expression-item"> - <span class="expression">[${expression}]</span> = - <span class="solution">${solution}</span> - </div> - `; - } - - generateSolvedExpressionsHTML() { - // Convert Set to Array and reverse it to get most recent first - const expressionsList = Array.from(this.state.solvedExpressions) - .reverse() - .map(expression => this._generateSolvedExpressionItemHTML( - expression, - this.PUZZLE_DATA.solutions[expression] - )) - .join(''); - - return ` - <div class="solved-expressions"> - <h3></h3> - <div class="expressions-list"> - ${expressionsList} - </div> - </div> - `; - } - - generateCompletionMessage() { - return ` - <div class="message success completion-message"> - <span><strong>** Puzzle Solved! **</strong></span><br> - <a href="${this.PUZZLE_DATA.completionURL}" - target="_blank" - rel="noopener noreferrer" - class="completion-url"> - ${this.PUZZLE_DATA.completionURL} - </a> - </div> - `; - } - - generateRankDisplay(stats) { - const margin = this.isMobile ? '0.5rem 0' : '1rem 0'; - const padding = this.isMobile ? '1rem' : '1.5rem'; - - // Custom messages for certain ranks - let nextRankMessage = ''; - - if (stats.rank === 'Puppet Master') { - nextRankMessage = '<br><span class="next-rank">now I am become Death, the destroyer of worlds</span>'; - } else if (stats.rank === 'Kingmaker') { - nextRankMessage = '<br><span class="next-rank">ah finally the very top</span>'; - } else if (stats.rank === 'Mayor') { - nextRankMessage = '<br><span class="next-rank">nobody\'s my boss, right?</span>'; - } else if (stats.rank === 'Power Broker') { - nextRankMessage = '<br><span class="next-rank">this has got to be it...</span>'; - } else if (stats.nextRankName) { - nextRankMessage = `<br><span class="next-rank">Almost: ${stats.nextRankName} (${Math.ceil(stats.pointsToNextRank)} points needed)</span>`; - } - - return ` - <div class="rank-display" data-rank="${stats.rank}"> - You are ${(stats.rank === 'Chief of Police' || stats.rank === 'Mayor') ? 'the' : 'a'} Bracket Shitty<br> - ${stats.rankEmoji} <b>${stats.rank}</b> ${stats.rankEmoji} - ${nextRankMessage} - <div class="share-hint"> - ${this.isMobile ? '(tap to share via text)' : '(click to copy results)'} - </div> - </div> - `; - } - - - /** - * Updated generateStatItems method to show different stats based on mode - */ - generateStatItems(stats) { - // Generate progress bar for score - const segments = 10; - const filledSegments = Math.round((stats.finalScore / 100) * segments); - const emptySegments = segments - filledSegments; - - // Use purple squares for Puppet Master, otherwise use color based on score - let segmentEmoji; - if (stats.rank === 'Puppet Master') { - segmentEmoji = '\u{1F7EA}'; // Purple Square emoji - } else { - segmentEmoji = stats.finalScore < 25 ? '\u{1F7E5}' : // Red - stats.finalScore < 75 ? '\u{1F7E8}' : // Yellow - '\u{1F7E9}'; // Green - } - - const progressBar = segmentEmoji.repeat(filledSegments) + '\u{2B1C}'.repeat(emptySegments); - - // Score bar HTML - const scoreBarHTML = ` - <div class="score-bar"> - <div class="score-value">Score: ${stats.finalScore.toFixed(1)}</div> - <div class="progress-bar">${progressBar}</div> - ${this.inputMode === 'classic' ? '<div class="hard-mode-indicator" style="font-size: 1rem; text-align: center; margin-top: 5px; font-weight: bold;">\u{2620}\u{FE0F} hard mode!</div>' : ''} - </div> - `; - - // For classic mode (keystroke-based) - if (this.inputMode === 'classic') { - return ` - ${scoreBarHTML} - <div class="stat-items"> - <div class="stat-item">Total keystrokes: ${this.state.totalKeystrokes}</div> - <div class="stat-item">Minimum keystrokes needed: ${this.state.minimumKeystrokes}</div> - <div class="stat-item">Excess keystrokes: ${this.state.totalKeystrokes - this.state.minimumKeystrokes}</div> - <div class="stat-item">\u{1F440} Clues peeked: ${this.state.peekedClues.size}</div> - <div class="stat-item">\u{1F6DF} Answers revealed: ${this.state.megaPeekedClues.size}</div> - <div class="stat-item"><b>Score breakdown</b> - <br>Base score (efficiency): ${stats.baseScore.toFixed(1)} - <br>Peek penalty: -${stats.peekPenalty.toFixed(1)} - ${stats.megaPeekPenalty > 0 ? `<br>Reveal penalty: -${stats.megaPeekPenalty.toFixed(1)}` : ''} - <br>Final score: ${stats.finalScore.toFixed(1)} - </div> - </div> - `; - } - // For submit mode (wrong-guess-based) - else { - return ` - ${scoreBarHTML} - <div class="stat-items"> - <div class="stat-item">\u{274C} Wrong guesses: ${this.state.wrongGuesses || 0}</div> - <div class="stat-item">\u{1F440} Clues peeked: ${this.state.peekedClues.size}</div> - <div class="stat-item">\u{1F6DF} Answers revealed: ${this.state.megaPeekedClues.size}</div> - <div class="stat-item"><b>Score breakdown</b> - <br>Base score: ${stats.baseScore.toFixed(1)} - ${stats.wrongGuessPenalty > 0 ? `<br>Wrong guess penalty: -${stats.wrongGuessPenalty.toFixed(1)}` : ''} - <br>Peek penalty: -${stats.peekPenalty.toFixed(1)} - ${stats.megaPeekPenalty > 0 ? `<br>Reveal penalty: -${stats.megaPeekPenalty.toFixed(1)}` : ''} - <br>Final score: ${stats.finalScore.toFixed(1)} - </div> - <div class="stat-item">Total keystrokes: ${this.state.totalKeystrokes}</div> - <div class="stat-item">Minimum keystrokes needed: ${this.state.minimumKeystrokes}</div> - <div class="stat-item">Excess keystrokes: ${this.state.totalKeystrokes - this.state.minimumKeystrokes}</div> - </div> - `; - } - } - - /************************** - UI setup and configuration - ***************************/ - - /** - * Sets up puzzle display and help system - */ - setupPuzzleDisplay() { - - const puzzleDisplay = this.root.querySelector('.puzzle-display'); - if (!puzzleDisplay) { - console.error('Puzzle display element not found'); - return; - } - - this.configurePuzzleDisplayLayout(puzzleDisplay); - this.setupHelpSystem(); - this.setupInfoSystem(); - this.setupClueClickHandlers(puzzleDisplay); - - // Add header click handler - const header = this.root.querySelector('.puzzle-header h1'); - if (header) { - header.addEventListener('click', async () => { - const todayDate = this.getNYCDate(); - if (this.currentPuzzleDate !== todayDate) { - await this.navigateToPuzzle(todayDate); - } - }); - } - } - - /** - * Configures puzzle display layout based on device - * @param {HTMLElement} puzzleDisplay - The puzzle display element - */ - configurePuzzleDisplayLayout(puzzleDisplay) { - const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - puzzleDisplay.classList.add(isMobile ? 'puzzle-display-mobile' : 'puzzle-display-desktop'); - } - - - /** - * Checks if the user has seen the current info version - * @returns {boolean} Whether the user has seen the current info - */ - hasSeenInfo() { - // Check if the user has seen the current version of the info - return localStorage.getItem('bracketCityInfoSeen') === this.INFO_VERSION; - } - - /** - * Marks the current info version as seen - */ - markInfoAsSeen() { - // Store the current version instead of just 'true' - localStorage.setItem('bracketCityInfoSeen', this.INFO_VERSION); - } - - /** - * Sets up the info system with an inline toggle switch - */ - setupInfoSystem() { - // Define the current info version - increment this to reset for all users - this.INFO_VERSION = '4'; // Increment version for the new layout - - const infoButton = this.root.querySelector('.info-button'); - if (!infoButton) return; - - // Check and apply the initial button state - if (!this.hasSeenInfo()) { - infoButton.classList.add('unseen-info'); - } - - // CSS for the iOS-style toggle - const toggleStyle = ` - <style> - /* iOS Toggle Switch */ - .inline-toggle { - position: relative; - display: inline-block; - width: 36px; - height: 20px; - vertical-align: middle; - margin: 0 6px; - } - - .inline-toggle input { - opacity: 0; - width: 0; - height: 0; - } - - .toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #ccc; - transition: .3s; - border-radius: 34px; - } - - .toggle-slider:before { - position: absolute; - content: ""; - height: 16px; - width: 16px; - left: 2px; - bottom: 2px; - background-color: white; - transition: .2s; - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0,0,0,0.2); - } - - input:checked + .toggle-slider { - background-color: #2563eb; - } - - input:disabled + .toggle-slider { - background-color: #d1d5db; - cursor: not-allowed; - } - - input:checked:disabled + .toggle-slider { - background-color: #93c5fd; - } - - input:checked + .toggle-slider:before { - transform: translateX(16px); - } - - .mode-disabled-message { - display: none; - } - - .toggle-disabled .mode-disabled-message { - display: inline; - } - </style> - `; - - // Build the info content with the toggle at the top - - // old news - //<div style="margin: 0.9em 0; font-size: 0.85em;">* clicking a <mark style="background-color: #fff9c4;">clue</mark> lets you peek at the answer's first letter</div> - //<div style="margin: 0.9em 0; font-size: 0.85em;">* clicking <span class='help-icon'>?</span> takes you to an interactive tutorial</div> - - const infoContent = ` - ${toggleStyle} - <div id="toggle-container" style="text-align: center; margin: 0.4em 0 1em 0;"> - <div style="display: flex; flex-direction: column; align-items: center; gap: 0;"> - <div style="display: flex; align-items: center; justify-content: center; gap: 6px;"> - <span style="font-weight: 500; font-size: 0.95em;">hard mode</span> - <label class="inline-toggle" onclick="event.stopPropagation();" style="margin: 0 0 0 2px;"> - <input type="checkbox" id="mode-toggle" onclick="event.stopPropagation();"> - <span class="toggle-slider"></span> - </label> - </div> - <div style="font-size: 0.7em; line-height: 1; margin-top: 1px; display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 4px;"> - <span style="font-style: italic; color: #6b7280;">no submit button & every keystroke counts!</span> - <span class="mode-disabled-message" style="color: #ef4444; font-weight: 500;"> - (can't change once a puzzle started) - </span> - </div> - </div> - </div> - - <div style="margin: 0.9em 0; font-size: 0.85em;">* there is now a <mark style="background-color:rgba(255,255,0,0.2)">date picker</mark> - just click the date in the header and browse the archive</div> - `; - - infoButton.addEventListener('click', (e) => { - e.stopPropagation(); - - // Mark as seen when clicked - if (!this.hasSeenInfo()) { - this.markInfoAsSeen(); - infoButton.classList.remove('unseen-info'); - } - - this.toggleHelp(infoContent); - - // Add event listener to the toggle switch - setTimeout(() => { - const modeToggle = document.getElementById('mode-toggle'); - const toggleContainer = document.getElementById('toggle-container'); - - if (!modeToggle || !toggleContainer) return; - - // Always evaluate current puzzle state - const hasPuzzleStarted = this.isPuzzleStarted(); - - // Set the checked state to match the current inputMode - modeToggle.checked = this.inputMode === 'classic'; - - // Update the disabled state and container class based on current puzzle state - modeToggle.disabled = hasPuzzleStarted; - toggleContainer.className = hasPuzzleStarted ? 'toggle-disabled' : ''; - - if (!modeToggle.disabled) { - modeToggle.addEventListener('change', (e) => { - e.stopPropagation(); - - // Double-check puzzle state before changing mode - if (this.isPuzzleStarted()) { - // If puzzle has been started since info panel opened, disable toggle and show message - modeToggle.disabled = true; - toggleContainer.className = 'toggle-disabled'; - // Reset toggle to match current mode - modeToggle.checked = this.inputMode === 'classic'; - return; - } - - // Update mode based on toggle state - // ON (checked) = classic mode, OFF (unchecked) = submit mode - const newMode = modeToggle.checked ? 'classic' : 'submit'; - this.setInputMode(newMode); - }); - } - - // Make sure all interactive elements stop propagation - toggleContainer.addEventListener('click', (e) => { - // Only stop propagation if clicking on the toggle itself - if (e.target === modeToggle || e.target.closest('.inline-toggle')) { - e.stopPropagation(); - } - }); - - // Add MutationObserver to update toggle state when puzzle state changes - this.setupToggleStateObserver(modeToggle, toggleContainer, isPuzzleStarted); - }, 100); - }); - } - - /** - * Checks if the user is a new player by examining localStorage - * @returns {boolean} True if this appears to be a new player - */ - isNewPlayer() { - let count = 0; - - // Count the number of localStorage keys that start with 'bracketCity' - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith('bracketPuzzle')) { - count++; - } - } - - // If more than one key is found, the player is not new - if (count > 1 || localStorage.getItem('tutorialSeen')) { - return false; - } - - // Otherwise, the player is considered new - return true; - } - - /** - * Sets up the help/tutorial system with special highlighting for new players - */ - setupHelpSystem() { - const helpButton = this.root.querySelector('.help-button'); - if (!helpButton) return; - - // Check if this is a new player to determine if we should highlight the tutorial button - if (this.isNewPlayer()) { - helpButton.classList.add('new-player-highlight'); - } - - helpButton.addEventListener('click', (e) => { - e.stopPropagation(); - - // Remove the highlighting when clicked - helpButton.classList.remove('new-player-highlight'); - - if (confirm('Would you like to start the tutorial?')) { - localStorage.setItem('tutorialSeen', 'true'); - this.saveState(); - window.location.href = 'tutorial'; - } - }); - } - - turnOffTutorialPulse() { - const helpButton = this.root.querySelector('.help-button'); - if (helpButton) { - helpButton.classList.remove('new-player-highlight'); - } - localStorage.setItem('tutorialSeen', 'true'); - } - - /** - * Toggles the help display with special handling for interactive elements - * @param {string} helpContent - The help content to display - */ - toggleHelp(helpContent) { - const puzzleDisplay = this.root.querySelector('.puzzle-display'); - if (!puzzleDisplay) return; - - // Check if the puzzle is completed - const isPuzzleCompleted = puzzleDisplay.classList.contains('completed'); - - // Get the info button - const infoButton = this.root.querySelector('.info-button'); - - // Store current puzzle state when help is shown - if (!this.state.helpVisible) { - // Opening help - this.updateGameState({ - helpVisible: true, - previousDisplay: puzzleDisplay.innerHTML - }); - - if (isPuzzleCompleted) { - // In completed state, just set content without scroll anchor - puzzleDisplay.innerHTML = helpContent; - // Don't auto-scroll in completed state - } else { - // Normal in-progress behavior with scroll anchor - puzzleDisplay.innerHTML = '<div id="help-scroll-anchor"></div>' + helpContent; - - // Scroll to anchor - works reliably on iOS Safari - const scrollAnchor = document.getElementById('help-scroll-anchor'); - if (scrollAnchor) { - scrollAnchor.scrollIntoView({block: 'start', behavior: 'auto'}); - } - } - - // Change the info button text from "!" to "X" - if (infoButton) { - infoButton.textContent = "X"; - } - - // Disable all inputs including submit button - this.disableInputsForHelp(); - - // Setup click handler for the puzzle display that closes help - // But don't close when clicking on interactive elements - puzzleDisplay.addEventListener('click', this.handleHelpClick = (event) => { - // Check if the click was on an interactive element - const isInteractiveElement = - event.target.tagName === 'INPUT' || - event.target.tagName === 'BUTTON' || - event.target.tagName === 'LABEL' || - event.target.tagName === 'A' || - event.target.classList.contains('toggle-container') || - event.target.classList.contains('toggle-labels') || - event.target.classList.contains('toggle-slider') || - event.target.closest('label') || - event.target.closest('a') || - event.target.closest('.toggle-container'); - - // Only close help if it wasn't an interactive element - if (!isInteractiveElement) { - this.closeHelp(); - } - }); - - } else { - this.closeHelp(); - } - } - - /** - * Closes the help panel and restores previous state - */ - closeHelp() { - const puzzleDisplay = this.root.querySelector('.puzzle-display'); - if (!puzzleDisplay) return; - - // Change the info button text back from "X" to "!" - const infoButton = this.root.querySelector('.info-button'); - if (infoButton) { - infoButton.textContent = "!"; - } - - // Remove the click handler to prevent memory leaks - if (this.handleHelpClick) { - puzzleDisplay.removeEventListener('click', this.handleHelpClick); - this.handleHelpClick = null; - } - - // Closing help - restore previous state and re-render - this.updateGameState({ - helpVisible: false, - previousDisplay: null - }); - - // Re-render the entire puzzle state - this.render(); - - // Re-enable input - const desktopInput = this.root.querySelector('.answer-input'); - if (desktopInput) { - desktopInput.disabled = false; - } - - // Re-enable submit button (desktop) - const submitButton = this.root.querySelector('.submit-answer'); - if (submitButton) { - submitButton.disabled = false; - submitButton.style.opacity = '1'; - submitButton.style.pointerEvents = 'auto'; - } - - // For mobile, re-enable keyboard and input - if (this.isMobile) { - // Restore custom input appearance - const customInput = this.root.querySelector('.custom-input'); - if (customInput) { - customInput.style.opacity = '1'; - customInput.style.pointerEvents = 'auto'; - } - - // Re-enable all keyboard keys - const keyboardKeys = this.root.querySelectorAll('.keyboard-key'); - keyboardKeys.forEach(key => { - key.disabled = false; - key.style.opacity = '1'; - key.style.pointerEvents = 'auto'; - }); - - // Re-enable mobile submit button - const mobileSubmitButton = this.root.querySelector('.mobile-submit-button'); - if (mobileSubmitButton) { - mobileSubmitButton.disabled = false; - mobileSubmitButton.style.opacity = '1'; - mobileSubmitButton.style.pointerEvents = 'auto'; - } - } - - // Restore focus when closing help (but only if not disabled) - if (desktopInput && !desktopInput.disabled) { - desktopInput.focus(); - } - } - - /** - * Sets up click handlers for puzzle clues - * @param {HTMLElement} puzzleDisplay - The puzzle display element - */ - setupClueClickHandlers(puzzleDisplay) { - puzzleDisplay.addEventListener('click', (event) => { - // If help is showing, check if we clicked on a link before closing - if (this.state.helpVisible) { - // Check if the click was on a link (or its descendants) - const isLinkClick = event.target.tagName === 'A' || - event.target.closest('a'); - - // Only close help if it wasn't a link click - if (!isLinkClick) { - this.toggleHelp(); - } - return; - } - - const clueElement = event.target.closest('.active-clue'); - if (!clueElement) return; - - // Find the clicked expression - const cleanText = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, ''); - const activeClues = this.findActiveClues(cleanText); - const cluePosition = Array.from(puzzleDisplay.querySelectorAll('.active-clue')).indexOf(clueElement); - - if (cluePosition >= 0 && cluePosition < activeClues.length) { - const expression = activeClues[cluePosition].expression.trim(); - this.toggleHintMode(expression); - - // Return focus to input on desktop - if (!this.isMobile && this.answerInput) { - this.answerInput.focus(); - } - } - }); - } - - setupCompletionState(puzzleDisplay) { - puzzleDisplay.classList.add('completed'); - - // scroll to bottom fix - document.body.style.overflow = 'auto'; - - if (this.isMobile) { - // Reduce bottom padding of puzzle display - puzzleDisplay.style.paddingBottom = '0.5rem'; - - // Adjust puzzle container styles for mobile - const container = this.root.querySelector('.puzzle-container'); - if (container) { - Object.assign(container.style, { - position: 'relative', - height: '100vh', - overflowY: 'auto', - display: 'block' - }); - } - - // Reduce top margin and padding in the content area to decrease vertical space - const content = this.root.querySelector('.puzzle-content'); - if (content) { - Object.assign(content.style, { - position: 'relative', - top: '1.3rem', - height: 'auto', - marginTop: '60px', // reduced from 85px - padding: '0.5rem', - paddingBottom: '1rem', - overflowY: 'visible' - }); - } - } - } - - attachCompletionHandlers(keystrokeStats) { - // Rank display click handler - const rankDisplay = keystrokeStats.querySelector('.rank-display'); - if (rankDisplay) { - rankDisplay.addEventListener('click', () => { - if (this.isMobile) { - this.shareSMS(); - } else { - this.shareResults(); - } - }); - } - } - - setupShareButtons() { - const copyButton = this.root.querySelector('.share-button.copy'); - const smsButton = this.root.querySelector('.share-button.sms'); - const resetButton = this.root.querySelector('.share-button.reset'); - - copyButton.addEventListener('click', () => this.shareResults()); - - if (!/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) { - smsButton.style.display = 'none'; - } - smsButton.addEventListener('click', () => this.shareSMS()); - resetButton.addEventListener('click', () => this.resetPuzzle()); - - } - - /************************** - Message system - ***************************/ - - /** - * Updates the game message with proper state management - * @param {string} text - Message text to display - * @param {string} type - Message type ('success' or 'error') - */ - showMessage(text, type = 'success') { - const messageEl = this.root.querySelector('.message'); - if (!messageEl) return; - - // Update state - this.updateGameState({ - message: text, - messageType: type - }); - - // Update message element - messageEl.textContent = text; - messageEl.className = `message ${type}`; - messageEl.style.display = 'block'; // Ensure message is visible - - // Auto-clear ALL messages (both success and error) - setTimeout(() => { - // Only clear if this message is still showing - if (this.state.message === text) { - this.updateGameState({ - message: '', - messageType: null - }); - messageEl.textContent = ''; - messageEl.className = 'message'; - messageEl.style.display = 'none'; - } - }, 1500); - } - - /** - * Shows an error message with special handling - * @param {string} message - Error message to display - */ - showErrorMessage(message) { - const messageEl = this.root.querySelector('.message') || document.createElement('div'); - - // If we need to create a new message element - if (!messageEl.parentNode) { - messageEl.className = 'message'; - this.root.prepend(messageEl); - } - - // Update state to track error - this.updateGameState({ - message, - messageType: 'error', - lastError: { - message, - timestamp: Date.now() - } - }); - } - - - /************************** - Share system - ***************************/ - - /** - * Modified generateShareText to only display mode when in classic/hard mode - */ - generateShareText() { - const puzzleDate = this.formatNYCDate(this.currentPuzzleDate); - const efficiency = ((this.state.minimumKeystrokes / this.state.totalKeystrokes) * 100); - - // Calculate rank and stats - const stats = this.rankCalculator.getDetailedStats( - efficiency, - this.state.peekedClues.size, - this.state.megaPeekedClues.size, - this.state.wrongGuesses || 0 - ); - - // Generate progress bar - const segments = 10; - const filledSegments = Math.round((stats.finalScore / 100) * segments); - const emptySegments = segments - filledSegments; - - // Use purple squares for Puppet Master, otherwise use color based on score - let segmentEmoji; - if (stats.rank === "Puppet Master") { - segmentEmoji = '\u{1F7EA}'; // Purple Square emoji - } else { - segmentEmoji = stats.finalScore < 25 ? '\u{1F7E5}' : // Red - stats.finalScore < 75 ? '\u{1F7E8}' : // Yellow - '\u{1F7E9}'; // Green - } - - const progressBar = segmentEmoji.repeat(filledSegments) + '\u{2B1C}'.repeat(emptySegments); - - // Build share text with specific line breaks to match desired layout - const shareItems = [ - '[Bracket Shitty]', - puzzleDate, - '', - 'https://user.4574.co.uk/bs', - '' - ]; - - // Only show "hard mode" message if in classic mode - if (this.inputMode === 'classic') { - shareItems.push('\u{2620}\u{FE0F} hard mode!'); - shareItems.push(''); // Add a line break between mode and rank - } - - // Add rank - shareItems.push(`Rank: ${stats.rankEmoji} (${stats.rank})`); - - // Only include mode-specific stats - // Replace it with this updated logic: - if (this.inputMode === 'classic' || stats.rank === 'Puppet Master') { - // Show keystroke stats in classic mode or for any Puppet Master - shareItems.push(`\u{1F3B9} Total Keystrokes: ${this.state.totalKeystrokes}`); - shareItems.push(`\u{1F3AF} Minimum Required: ${this.state.minimumKeystrokes}`); - } else { - // Submit mode stats - shareItems.push(`\u{274C} Wrong guesses: ${this.state.wrongGuesses || 0}`); - } - - // Add peek and reveal counts only if they exist - if (this.state.peekedClues.size > 0) { - shareItems.push(`\u{1F440} Peeks: ${this.state.peekedClues.size}`); - } - - if (this.state.megaPeekedClues.size > 0) { - shareItems.push(`\u{1F6DF} Answers Revealed: ${this.state.megaPeekedClues.size}`); - } - - // Remove the input mode indicator - we now only show it for classic mode above the rank - - // Add score and progress bar with an empty line before score - shareItems.push(''); - shareItems.push(`Total Score: ${stats.finalScore.toFixed(1)}`); - shareItems.push(progressBar); - - return shareItems.join('\n'); - } - - /** - * Handles sharing results via the user's preferred app using Web Share API - * Falls back to SMS URL scheme if Web Share API is not available - */ - shareSMS() { - const shareText = this.generateShareText(); - - // Check if Web Share API is available - if (navigator.share) { - navigator.share({ - title: 'Bracket Shitty Results', - text: shareText - }) - .then(() => { - console.log('Successfully shared results'); - }) - .catch((error) => { - console.error('Error sharing results:', error); - // Fall back to SMS URL scheme if sharing fails - this.fallbackToSMS(shareText); - }); - } else { - // Fall back to SMS URL scheme for older browsers - this.fallbackToSMS(shareText); - } - } - - /** - * Fallback method to use SMS URL scheme when Web Share API is not available - * @param {string} shareText - The text to share - */ - fallbackToSMS(shareText) { - const encodedText = encodeURIComponent(shareText); - - // Use correct SMS URL scheme format - window.location.href = `sms:?&body=${encodedText}`; - } - - /** - * Copies results to clipboard for desktop sharing - */ -shareResults() { - const shareText = this.generateShareText(); - - // Use navigator.clipboard API for modern browsers - if (navigator.clipboard) { - navigator.clipboard.writeText(shareText) - .then(() => { - // Show success message - const shareMessage = this.root.querySelector('.share-message'); - if (shareMessage) { - shareMessage.style.display = 'block'; - setTimeout(() => { - shareMessage.style.display = 'none'; - }, 2000); - } - }) - .catch(err => { - console.error('Failed to copy results: ', err); - // Show error or fallback to execCommand - this.fallbackCopyToClipboard(shareText); - }); - } else { - // Fallback for older browsers - this.fallbackCopyToClipboard(shareText); - } -} - -/** - * Fallback method to copy text to clipboard using execCommand - * @param {string} text - Text to copy - */ -fallbackCopyToClipboard(text) { - try { - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.style.position = 'fixed'; // Avoid scrolling to bottom - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - const successful = document.execCommand('copy'); - if (successful) { - // Show success message - const shareMessage = this.root.querySelector('.share-message'); - if (shareMessage) { - shareMessage.style.display = 'block'; - setTimeout(() => { - shareMessage.style.display = 'none'; - }, 2000); - } - } else { - console.error('execCommand failed'); - } - - document.body.removeChild(textArea); - } catch (err) { - console.error('Fallback copy method failed: ', err); - } -} - - /************************** - Utility methods - ***************************/ - - /** - * Resets the input container to initial state - * @param {HTMLElement} inputContainer - The input container element - */ - resetInputContainer(inputContainer) { - inputContainer.innerHTML = ` - <input type="text" placeholder="type any answer..." class="answer-input" autocomplete="off" autocapitalize="off"> - <div class="message"></div> - `; - // Re-setup input handlers for the new input element - this.setupInputHandlers(); - } - - /** - * Gets all required DOM elements - * @returns {Object} Object containing DOM elements - */ - getDOMElements() { - return { - puzzleDisplay: this.root.querySelector('.puzzle-display'), - expressionsList: this.root.querySelector('.expressions-list'), - inputContainer: this.root.querySelector('.input-container'), - keystrokeStats: this.root.querySelector('.keystroke-stats'), - message: this.root.querySelector('.message') - }; - } - - /** - * Validates that all required elements exist - * @param {Object} elements - DOM elements object - * @returns {boolean} Whether all required elements exist - */ - validateElements(elements) { - return Object.values(elements).every(element => element !== null); - } - - /** - * Checks if the puzzle is complete - * @returns {boolean} Whether the puzzle is complete - */ - - isPuzzleComplete() { - return !this.state.displayState.includes('['); - } - - cleanupEventListeners() { - const prevButton = this.root.querySelector('.nav-button.prev'); - const nextButton = this.root.querySelector('.nav-button.next'); - if (prevButton) prevButton.replaceWith(prevButton.cloneNode(true)); - if (nextButton) nextButton.replaceWith(nextButton.cloneNode(true)); - } - - /** - * Creates and displays an announcement modal - * @param {string} title - The title of the announcement - * @param {string} message - The message content (can include HTML) - * @param {string} buttonText - The text for the dismiss button - * @param {string} modalId - Unique identifier for the modal - * @returns {boolean} Whether the modal was shown - */ - showAnnouncementModal(title, message, buttonText = 'Got it', modalId) { - // Check if we should show this modal - if (!modalId || this.hasSeenAnnouncement(modalId)) { - return false; - } - - // Create modal HTML - const modalHTML = ` - <div class="announcement-modal-overlay" id="announcement-overlay"> - <div class="announcement-modal"> - <div class="announcement-modal-title">${title}</div> - <div class="announcement-modal-content">${message}</div> - <button class="announcement-modal-button" id="announcement-dismiss">${buttonText}</button> - </div> - </div> - `; - - // Append to the body - document.body.insertAdjacentHTML('beforeend', modalHTML); - - // Get DOM references - const overlay = document.getElementById('announcement-overlay'); - const dismissButton = document.getElementById('announcement-dismiss'); - - // Add event listeners - dismissButton.addEventListener('click', () => { - this.dismissAnnouncementModal(modalId); - }); - - // Close on outside click too - overlay.addEventListener('click', (e) => { - if (e.target === overlay) { - this.dismissAnnouncementModal(modalId); - } - }); - - // Show the modal with a small delay - setTimeout(() => { - overlay.classList.add('visible'); - }, 100); - - return true; - } - - /** - * Dismisses the announcement modal and marks it as seen - * @param {string} modalId - Unique identifier for the modal - */ - dismissAnnouncementModal(modalId) { - const overlay = document.getElementById('announcement-overlay'); - - if (!overlay) return; - - // Add fade-out animation - overlay.classList.remove('visible'); - - // Remove from DOM after animation completes - setTimeout(() => { - overlay.remove(); - - if (!this.isMobile) { - const inputElement = this.root.querySelector('.answer-input'); - if (inputElement && !inputElement.disabled) { - inputElement.focus(); - } - } - - }, 300); - - // Mark as seen - if (modalId) { - this.markAnnouncementAsSeen(modalId); - } - } - - /** - * Checks if the user has seen a specific announcement - * @param {string} modalId - Unique identifier for the modal - * @returns {boolean} Whether the announcement has been seen - */ - hasSeenAnnouncement(modalId) { - const storageKey = `bracketCityAnnouncement_${modalId}`; - return localStorage.getItem(storageKey) === 'seen'; - } - - /** - * Marks an announcement as seen in localStorage - * @param {string} modalId - Unique identifier for the modal - */ - markAnnouncementAsSeen(modalId) { - const storageKey = `bracketCityAnnouncement_${modalId}`; - localStorage.setItem(storageKey, 'seen'); - } - - /** - * Helper method to display an important announcement - * @param {Object} options - Announcement options - * @param {string} options.title - The title of the announcement - * @param {string} options.message - The announcement message - * @param {string} options.buttonText - Text for the dismiss button - * @param {string} options.id - Unique identifier for this announcement - * @returns {boolean} Whether the announcement was shown - */ - showImportantAnnouncement(options) { - const { - title = 'Important Announcement', - message = '', - buttonText = 'Got it', - id = 'default-announcement' - } = options; - - return this.showAnnouncementModal(title, message, buttonText, id); - } -} |