aboutsummaryrefslogtreecommitdiff
path: root/static/bs/bracket.js
diff options
context:
space:
mode:
Diffstat (limited to 'static/bs/bracket.js')
-rw-r--r--static/bs/bracket.js4551
1 files changed, 4551 insertions, 0 deletions
diff --git a/static/bs/bracket.js b/static/bs/bracket.js
new file mode 100644
index 0000000..0e5bb94
--- /dev/null
+++ b/static/bs/bracket.js
@@ -0,0 +1,4551 @@
+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.EMAIL_API_ENDPOINT = 'https://cgirhn3gi2.execute-api.us-east-2.amazonaws.com/emails';
+ this.IDENTITY_POOL_ID = 'us-east-2:437434cd-7573-48a7-b2d7-607d88c725fe';
+ this.REGION = 'us-east-2';
+
+ 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();
+
+ this.gaCompletionFired = false;
+ this.gaStartFired = false;
+
+ // 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();
+ }
+
+ async loadAwsModules() {
+ // Only load once
+ if (window.AWS && window.AWS.CognitoIdentity && window.AWS.SignatureV4) {
+ return;
+ }
+
+ // Load required modules dynamically
+ await Promise.all([
+ this.loadScript('https://sdk.amazonaws.com/js/aws-sdk-2.1048.0.min.js'),
+ ]);
+ }
+
+ // Helper to load a script
+ loadScript(src) {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = src;
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ }
+
+ async makeSignedRequest(path, options = {}) {
+
+ // Load AWS modules just-in-time
+ await this.loadAwsModules();
+
+ // Initialize AWS configuration
+ AWS.config.region = this.REGION;
+ AWS.config.credentials = new AWS.CognitoIdentityCredentials({
+ IdentityPoolId: this.IDENTITY_POOL_ID
+ });
+
+ await AWS.config.credentials.getPromise();
+
+ const endpoint = new AWS.Endpoint(this.EMAIL_API_ENDPOINT);
+ const request = new AWS.HttpRequest(endpoint, this.REGION);
+
+ request.method = options.method || 'GET';
+
+ request.path = endpoint.path + (path === '/' ? '' : path);
+
+ request.headers = {
+ 'Host': endpoint.host
+ };
+
+ if (options.body) {
+ request.body = options.body;
+ request.headers['Content-Type'] = 'application/json';
+ }
+
+ const signer = new AWS.Signers.V4(request, 'execute-api');
+ signer.addAuthorization(AWS.config.credentials, AWS.util.date.getDate());
+
+ const fetchOptions = {
+ method: request.method,
+ headers: request.headers
+ };
+
+ if (options.body && request.method !== 'GET') {
+ fetchOptions.body = options.body;
+ }
+
+ return fetch(`${this.EMAIL_API_ENDPOINT}${path === '/' ? '' : path}`, fetchOptions);
+
+ }
+
+ /**
+ * 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 City] \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 City!</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.');
+
+ gtag('event', 'error', {
+ 'error_type': 'initialization_error',
+ 'error_message': error.message
+ });
+ }
+ }
+
+ initializeAnalytics() {
+
+ // Only fire once.
+ if (this.gaStartFired) return;
+ this.gaStartFired = true;
+
+ // Track puzzle start
+ gtag('event', 'puzzle_start', {
+ 'puzzle_date': this.currentPuzzleDate,
+ 'user_id': this.userId,
+ 'device_type': this.isMobile ? 'mobile' : 'desktop'
+ });
+ console.log('Fired puzzle_start event');
+ }
+
+ /**
+ * 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);
+ }
+
+ // Merge emailSignup if present
+ if (updates.emailSignup) {
+ newState.emailSignup = {
+ ...this.state.emailSignup,
+ ...updates.emailSignup
+ };
+ }
+
+ // 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,
+ // Preserve email signup state
+ emailSignup: parsed.emailSignup || {
+ email: '',
+ status: 'idle',
+ errorMessage: ''
+ }
+ };
+
+ } catch (error) {
+ console.error('Error loading saved state:', error);
+ gtag('event', 'error', {
+ 'error_type': 'load_state_error',
+ 'error_message': error.message
+ });
+ 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);
+ gtag('event', 'error', {
+ 'error_type': 'save_state_error',
+ 'error_message': error.message
+ });
+ 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;
+ }
+
+ // Track reset event if needed.
+ gtag('event', 'puzzle_reset', {
+ 'puzzle_date': this.currentPuzzleDate,
+ 'progress': this.state.solvedExpressions.size
+ });
+
+ // 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
+ emailSignup: {
+ email: '',
+ status: 'idle',
+ errorMessage: ''
+ }
+ });
+
+ // 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);
+ gtag('event', 'error', {
+ 'error_type': 'puzzle_fetch_error',
+ 'error_message': error.message
+ });
+ 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, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ 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');
+
+ gtag('event', 'wrong_guess', {
+ 'puzzle_date': this.currentPuzzleDate,
+ 'input': input,
+ 'wrong_guesses': this.state.wrongGuesses
+ });
+ }
+
+ // 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, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ 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 = '';
+ }
+ }
+
+ if (isFirstSolve) {
+ gtag('event', 'solved_one_clue', {
+ 'puzzle_date': this.currentPuzzleDate,
+ 'user_id': this.userId,
+ 'device_type': this.isMobile ? 'mobile' : 'desktop'
+ });
+ }
+
+ // 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, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ 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])
+ });
+
+ gtag('event', 'peek_used', {
+ 'puzzle_date': this.currentPuzzleDate,
+ 'user_id': this.userId,
+ 'device_type': this.isMobile ? 'mobile' : 'desktop'
+ });
+
+ 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))
+ });
+
+ gtag('event', 'mega_peek_used', {
+ 'puzzle_date': this.currentPuzzleDate,
+ 'user_id': this.userId,
+ 'device_type': this.isMobile ? 'mobile' : 'desktop'
+ });
+
+ 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);
+
+ // Track calendar open event
+ gtag('event', 'calendar_opened', {
+ 'current_puzzle_date': this.currentPuzzleDate,
+ 'device_type': isMobile ? 'mobile' : 'desktop'
+ });
+ } 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;">&lsaquo;</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;">&rsaquo;</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';
+
+ // Track date selection event
+ gtag('event', 'calendar_date_selected', {
+ 'selected_date': targetDate,
+ 'from_date': this.currentPuzzleDate,
+ 'puzzle_status': cell.dataset.status,
+ 'device_type': /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ? 'mobile' : 'desktop'
+ });
+
+ 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);
+
+ // Preserve email signup state before resetting state
+ const preservedEmailSignup = this.state?.emailSignup || {
+ email: '',
+ status: 'idle',
+ errorMessage: ''
+ };
+
+ // Reset state to prevent leakage, but preserve things like email signup
+ 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,
+ emailSignup: preservedEmailSignup
+ };
+
+ // 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,
+ // Don't override the preserved email signup state unless there's a saved one
+ emailSignup: savedState.emailSignup || preservedEmailSignup
+ });
+ } 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,
+ // Keep the preserved email signup state
+ emailSignup: preservedEmailSignup
+ });
+ }
+
+ // 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();
+
+ // Track navigation event
+ gtag('event', 'puzzle_navigation', {
+ 'from_date': previousDate,
+ 'to_date': targetDate,
+ 'direction': targetDate > previousDate ? 'next' : 'previous'
+ });
+
+ } catch (error) {
+ console.error('Navigation failed:', error);
+ this.showErrorMessage('Failed to load puzzle. Please try again.');
+ gtag('event', 'error', {
+ 'error_type': 'navigation_error',
+ 'error_message': error.message,
+ 'target_date': targetDate
+ });
+ } 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.generateEmailSignup()}
+ ${this.generateStatItems(stats)}
+ `;
+
+ this.attachCompletionHandlers(keystrokeStats);
+
+ // Scroll to top
+ setTimeout(() => {
+ window.scrollTo({
+ top: 0,
+ behavior: 'smooth'
+ });
+ this.setupDatePicker();
+ this.trackCompletion();
+ }, 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.generateEmailSignup()}
+ ${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'}>&larr;</button>
+ <div class="puzzle-date">${this.formatNYCDate(displayDate)}</div>
+ <button class="nav-button next" ${!this.isMobile && 'disabled'}>&rarr;</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"
+ onclick="gtag('event', 'click', {
+ 'event_category': 'puzzle',
+ 'event_label': 'completion_url',
+ 'value': 1
+ });">
+ ${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 City<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>
+ `;
+ }
+
+
+ generateEmailSignup() {
+ return `
+ <div class="email-signup-container">
+ <form class="email-signup-form">
+ <label for="email">
+ <span>
+ [want to know when new puzzles are ready?]
+ </span>
+ </label>
+ <div class="email-input-container">
+ <input
+ type="email"
+ id="email"
+ placeholder="Enter your email"
+ ${this.state.emailSignup?.status === 'loading' || this.state.emailSignup?.status === 'success' ? 'disabled' : ''}
+ >
+ <button
+ type="submit"
+ ${this.state.emailSignup?.status === 'loading' || this.state.emailSignup?.status === 'success' ? 'disabled' : ''}
+ >
+ ${this.state.emailSignup?.status === 'loading' ? 'Signing up...' : '[sign up]'}
+ </button>
+ </div>
+ <div class="email-signup-message"></div>
+ <label for="email">
+ <span>
+ [emails include a fun word of the day]
+ </span>
+ </label>
+ </form>
+ </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() {
+
+ // puzzle start event
+ this.initializeAnalytics();
+
+ 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>
+
+ <div style="margin: 0.9em 0; font-size: 0.85em;">* check out the <a href="https://bracket.city/words">word of the day</a> written by friend of the city <a href="https://jsomers.net">james somers</a></div>
+
+ <div style="margin: 1.5em 0 0 0; text-align: center; font-size: 0.85em; color: #666;">fan mail: <a href="mailto:mayor@bracket.city">mayor@bracket.city</a></div>
+ `;
+
+ infoButton.addEventListener('click', (e) => {
+ e.stopPropagation();
+
+ gtag('event', 'info_viewed', {
+ 'info_version': this.INFO_VERSION,
+ 'user_id': this.userId,
+ 'puzzle_date': this.currentPuzzleDate,
+ 'first_view': !this.hasSeenInfo(),
+ 'input_mode': this.inputMode
+ });
+
+ // 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);
+
+ gtag('event', 'input_mode_changed', {
+ 'mode': newMode,
+ 'user_id': this.userId
+ });
+ });
+ }
+
+ // 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();
+ // Track tutorial activation
+ gtag('event', 'tutorial_start', {
+ 'puzzle_date': this.currentPuzzleDate,
+ 'user_id': this.userId,
+ 'device_type': this.isMobile ? 'mobile' : 'desktop',
+ 'new_player': this.isNewPlayer()
+ });
+ 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'
+ });
+ }
+ }
+ }
+
+ /**
+ * Tracks completion analytics including the input mode
+ */
+ trackCompletion() {
+ // Only fire once.
+ if (this.gaCompletionFired) return;
+ this.gaCompletionFired = true;
+
+ // Compute efficiency, etc.
+ const efficiency = this.state.totalKeystrokes > 0
+ ? ((this.state.minimumKeystrokes / this.state.totalKeystrokes) * 100)
+ : 100;
+
+ const stats = this.rankCalculator.getDetailedStats(
+ efficiency,
+ this.state.peekedClues.size,
+ this.state.megaPeekedClues.size,
+ this.state.wrongGuesses || 0
+ );
+
+ const payload = {
+ puzzle_date: this.currentPuzzleDate,
+ user_id: this.userId,
+ device_type: this.isMobile ? 'mobile' : 'desktop',
+ final_rank: stats.rank,
+ peek_count: this.state.peekedClues.size,
+ mega_peek_count: this.state.megaPeekedClues.size,
+ wrong_guess_count: this.state.wrongGuesses || 0,
+ input_mode: this.inputMode
+ };
+
+ console.log('Firing GA event with payload:', payload);
+ try {
+ window.gtag('event', 'puzzle_complete', payload);
+ } catch (e) {
+ console.error('Error firing GA event:', e);
+ }
+ }
+
+
+
+ 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();
+ }
+ });
+ }
+
+ // Email form handler
+ const emailForm = keystrokeStats.querySelector('.email-signup-form');
+ if (emailForm) {
+ emailForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const emailInput = emailForm.querySelector('#email');
+ const email = emailInput.value.trim();
+ await this.handleEmailSignup(email);
+ this.renderEmailSignupState();
+ });
+ }
+ }
+
+ 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()
+ }
+ });
+ }
+
+ /**************************
+ Email system
+ ***************************/
+
+ // New method for email validation
+ validateEmail(email) {
+ const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return regex.test(email);
+ }
+
+ async handleEmailSignup(email) {
+ // Update state to loading immediately
+ this.updateGameState({
+ emailSignup: {
+ ...this.state.emailSignup,
+ status: 'loading',
+ errorMessage: ''
+ }
+ });
+
+ // Re-render immediately to show loading state
+ this.renderEmailSignupState();
+
+ // Validate email format
+ if (!this.validateEmail(email)) {
+ this.updateGameState({
+ emailSignup: {
+ ...this.state.emailSignup,
+ status: 'error',
+ errorMessage: 'Please enter a valid email address'
+ }
+ });
+ this.renderEmailSignupState();
+ return;
+ }
+
+ try {
+ const response = await this.makeSignedRequest('/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ email })
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ if (response.status === 409) {
+ throw new Error('This email is already registered!');
+ }
+ throw new Error(data.message || 'Failed to sign up');
+ }
+
+ // Update state to success
+ this.updateGameState({
+ emailSignup: {
+ email: '',
+ status: 'success',
+ errorMessage: ''
+ }
+ });
+
+ // Track successful signup
+ gtag('event', 'email_signup_success', {
+ 'puzzle_date': this.this.currentPuzzleDate
+ });
+
+ } catch (error) {
+ this.updateGameState({
+ emailSignup: {
+ ...this.state.emailSignup,
+ status: 'error',
+ errorMessage: error.message || 'Failed to sign up. Please try again.'
+ }
+ });
+
+ }
+
+ // Re-render to reflect final state
+ this.renderEmailSignupState();
+ }
+
+ renderEmailSignupState() {
+ // Defensive check to ensure emailSignup is initialized
+ if (!this.state.emailSignup) {
+ console.warn('emailSignup is undefined, initializing to default.');
+ this.updateGameState({
+ emailSignup: {
+ email: '',
+ status: 'idle',
+ errorMessage: ''
+ }
+ });
+ return; // Exit early to allow state to update
+ }
+
+ const form = this.root.querySelector('.email-signup-form');
+ const message = this.root.querySelector('.email-signup-message');
+ const input = this.root.querySelector('#email');
+ const submitButton = form?.querySelector('button[type="submit"]');
+
+ if (!form || !message || !input || !submitButton) return;
+
+ // Update input and button state
+ input.disabled = this.state.emailSignup.status === 'loading' ||
+ this.state.emailSignup.status === 'success';
+ submitButton.disabled = input.disabled;
+
+ // Update button text
+ submitButton.textContent = this.state.emailSignup.status === 'loading'
+ ? 'Signing up...'
+ : 'Sign up';
+
+ // Clear input on success
+ if (this.state.emailSignup.status === 'success') {
+ input.value = '';
+ }
+
+ // Update message
+ if (this.state.emailSignup.status === 'success') {
+ message.innerHTML = `
+ <div class="success">
+ Thanks for signing up! The mayor will let you know when new puzzles are available.
+ </div>
+ `;
+ } else if (this.state.emailSignup.status === 'error') {
+ message.innerHTML = `
+ <div class="error">
+ ${this.state.emailSignup.errorMessage}
+ </div>
+ `;
+ } else {
+ message.innerHTML = '';
+ }
+ }
+
+ /**************************
+ 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 City]',
+ puzzleDate,
+ '',
+ 'https://bracket.city',
+ ''
+ ];
+
+ // 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() {
+ // Track share attempt
+ gtag('event', 'share_sms', {
+ 'puzzle_date': this.currentPuzzleDate
+ });
+
+ const shareText = this.generateShareText();
+
+ // Check if Web Share API is available
+ if (navigator.share) {
+ navigator.share({
+ title: 'Bracket City 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() {
+ // Track copy attempt
+ gtag('event', 'share_copy', {
+ 'puzzle_date': this.currentPuzzleDate
+ });
+
+ 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);
+
+ // Track announcement view in analytics if available
+ if (window.gtag) {
+ gtag('event', 'view_announcement', {
+ 'modal_id': modalId
+ });
+ }
+
+ 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);
+ }
+
+ // Track dismissal in analytics if available
+ if (window.gtag) {
+ gtag('event', 'dismiss_announcement', {
+ 'modal_id': 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);
+ }
+}