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