cloze-reader / src /app.js
milwright
clean production deployment with comprehensive readme
a6d0aac
// Main application entry point
import ClozeGame from './clozeGameEngine.js';
import ChatUI from './chatInterface.js';
import WelcomeOverlay from './welcomeOverlay.js';
import { LeaderboardUI } from './leaderboardUI.js';
import { analyticsService } from './analyticsService.js';
class App {
constructor() {
this.game = new ClozeGame();
this.chatUI = new ChatUI(this.game);
this.welcomeOverlay = new WelcomeOverlay();
this.leaderboardUI = new LeaderboardUI(this.game.leaderboardService);
this.analytics = analyticsService;
// Prevent double-trigger on rapid Skip clicks
this._skipInProgress = false;
this.lastRevealWasSkip = false; // Controls reveal styling after skip
this.elements = {
loading: document.getElementById('loading'),
gameArea: document.getElementById('game-area'),
stickyControls: document.getElementById('sticky-controls'),
bookInfo: document.getElementById('book-info'),
roundInfo: document.getElementById('round-info'),
streakInfo: document.getElementById('streak-info'),
contextualization: document.getElementById('contextualization'),
passageContent: document.getElementById('passage-content'),
hintsSection: document.getElementById('hints-section'),
hintsList: document.getElementById('hints-list'),
skipBtn: document.getElementById('skip-btn'),
submitBtn: document.getElementById('submit-btn'),
nextBtn: document.getElementById('next-btn'),
hintBtn: document.getElementById('hint-btn'),
result: document.getElementById('result'),
leaderboardBtn: document.getElementById('leaderboard-btn')
};
this.currentResults = null;
this.isRetrying = false; // Track if we're in retry mode
this.setupEventListeners();
}
async initialize() {
try {
this.showLoading(true);
await this.game.initialize();
await this.startNewGame();
this.showLoading(false);
} catch (error) {
console.error('Failed to initialize app:', error);
this.showError('Failed to load the game. Please refresh and try again.');
}
}
setupEventListeners() {
if (this.elements.skipBtn) {
this.elements.skipBtn.addEventListener('click', () => {
// Ensure any unexpected async errors are surfaced but don’t crash UI
Promise.resolve(this.handleSkip()).catch(err => console.error('handleSkip (event) ERROR:', err));
});
} else {
}
this.elements.submitBtn.addEventListener('click', () => this.handleSubmit());
this.elements.nextBtn.addEventListener('click', () => this.handleNext());
this.elements.hintBtn.addEventListener('click', () => this.toggleHints());
// Leaderboard button
if (this.elements.leaderboardBtn) {
this.elements.leaderboardBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.leaderboardUI.show();
});
}
// Note: Enter key handling is done per-input in setupInputListeners()
}
async startNewGame() {
try {
const roundData = await this.game.startNewRound();
this.displayRound(roundData);
this.resetUI();
} catch (error) {
console.error('Error starting new game:', error);
this.showError('Could not load a new passage. Please try again.');
}
}
displayRound(roundData) {
// Start analytics tracking for this passage
this.analytics.startPassage(
{ title: roundData.title, author: roundData.author },
roundData.blanks,
this.game.currentLevel,
this.game.currentRound
);
// Reset retry state
this.isRetrying = false;
// Show book information
this.elements.bookInfo.innerHTML = `
<strong>${roundData.title}</strong> by ${roundData.author}
`;
// Show level information
const blanksCount = roundData.blanks.length;
const levelInfo = `Level ${this.game.currentLevel}${blanksCount} blank${blanksCount > 1 ? 's' : ''}`;
this.elements.roundInfo.innerHTML = levelInfo;
// Update streak display
this.updateStreakDisplay();
// Show contextualization from AI agent
this.elements.contextualization.innerHTML = `
<div class="flex items-start gap-2">
<span class="text-blue-600">📜</span>
<span>${roundData.contextualization || 'Loading context...'}</span>
</div>
`;
// Render the cloze text with input fields and chat buttons
const clozeHtml = this.game.renderClozeTextWithChat();
this.elements.passageContent.innerHTML = `<p>${clozeHtml}</p>`;
// Store hints for later display
this.currentHints = roundData.hints || [];
this.populateHints();
// Hide hints initially
this.elements.hintsSection.style.display = 'none';
// Set up input field listeners
this.setupInputListeners();
// Set up chat buttons
this.chatUI.setupChatButtons();
}
setupInputListeners() {
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
inputs.forEach((input, index) => {
input.addEventListener('input', () => {
// Remove any previous styling
input.classList.remove('correct', 'incorrect');
this.updateSubmitButton();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
// Move to next input or submit if last
const nextInput = inputs[index + 1];
if (nextInput) {
nextInput.focus();
} else {
this.handleSubmit();
}
}
});
});
// Focus first input
if (inputs.length > 0) {
inputs[0].focus();
}
}
updateSubmitButton() {
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
// Only check non-disabled (non-locked) inputs
const nonLockedInputs = Array.from(inputs).filter(input => !input.disabled);
const allFilled = nonLockedInputs.every(input => input.value.trim() !== '');
this.elements.submitBtn.disabled = !allFilled || nonLockedInputs.length === 0;
}
handleSubmit() {
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
const answers = Array.from(inputs).map(input => input.value.trim());
// Check if all non-locked fields are filled
const nonLockedInputs = Array.from(inputs).filter((input, index) => !this.game.isBlankLocked(index));
const nonLockedAnswers = nonLockedInputs.map(input => input.value.trim());
if (nonLockedAnswers.some(answer => answer === '')) {
alert('Please fill in all blanks before submitting.');
return;
}
// Submit answers and get results
this.currentResults = this.game.submitAnswers(answers);
// Record attempts in analytics only for blanks attempted in this submission
this.currentResults.results.forEach(result => {
if (result.attemptedThisRound) {
this.analytics.recordAttempt(result.blankIndex, result.isCorrect);
}
});
// Handle retry vs final display
if (this.currentResults.canRetry) {
this.displayRetryUI(this.currentResults);
} else {
// Final submission - send analytics
this.sendAnalytics(this.currentResults.passed);
this.displayResults(this.currentResults);
}
}
/**
* Display UI for retry mode - lock correct answers, highlight wrong ones
*/
displayRetryUI(results) {
this.isRetrying = true;
// Ensure skip can be used during this retry cycle
this._skipInProgress = false;
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
results.results.forEach((result, index) => {
const input = inputs[index];
if (!input) return;
if (result.isCorrect || result.isLocked) {
// Lock correct answers - show green background and disable
input.classList.add('correct');
input.classList.remove('incorrect');
input.style.backgroundColor = '#dcfce7'; // Light green
input.style.borderColor = '#16a34a';
input.disabled = true;
input.value = result.correctAnswer;
} else {
// Highlight wrong answers - show red border, keep editable
input.classList.add('incorrect');
input.classList.remove('correct');
input.style.backgroundColor = '#fef2f2'; // Light red
input.style.borderColor = '#dc2626';
input.disabled = false;
// Clear wrong answer for retry
input.value = '';
}
});
// Update result message
this.elements.result.textContent = results.feedbackText;
this.elements.result.className = 'mt-4 text-center font-semibold text-amber-600';
// Update submit button text and show skip button
this.elements.submitBtn.textContent = 'Try Again';
this.elements.submitBtn.disabled = true; // Will be enabled when user types
if (this.elements.skipBtn) {
this.elements.skipBtn.classList.remove('hidden');
this.elements.skipBtn.disabled = false; // Ensure skip is clickable on retries
this.elements.skipBtn.removeAttribute('disabled');
this.elements.skipBtn.setAttribute('aria-disabled', 'false');
} else {
}
// Focus first wrong input
const firstWrongInput = Array.from(inputs).find(
(input, index) => results.retryableIndices.includes(index)
);
if (firstWrongInput) {
firstWrongInput.focus();
}
// Don't update streak display during retry - wait for final result
}
/**
* Handle skip button - skip passage and move to next
*/
async handleSkip() {
console.log('handleSkip: START - v2');
try {
// Debounce multiple rapid clicks
if (this._skipInProgress) {
console.warn('handleSkip: already in progress, ignoring');
return;
}
this._skipInProgress = true;
if (this.elements?.skipBtn) {
this.elements.skipBtn.disabled = true;
}
// Early exit if no game or blanks
if (!this.game || !Array.isArray(this.game.blanks) || this.game.blanks.length === 0) {
console.warn('handleSkip: No game/blanks, going to next passage');
this.handleNext();
return;
}
// Send analytics as failed (non-blocking)
console.log('📊 Sending analytics (skip - passage failed)...');
this.sendAnalytics(false).catch(err => console.warn('📊 Analytics (skip) failed non-critically:', err));
// Use engine to force-complete this passage to ensure consistent results/state
const finalResults = this.game.forceCompletePassage();
console.log('handleSkip: forceCompletePassage results:', finalResults);
// Show the results (which reveals correct answers)
console.log('handleSkip: calling displayResults');
this.lastRevealWasSkip = true;
this.displayResults(finalResults);
console.log('handleSkip: displayResults completed');
// Hide skip button
this.elements.skipBtn.classList.add('hidden');
// Hide submit button
this.elements.submitBtn.style.display = 'none';
// Show the next button immediately after skip
this.elements.nextBtn.classList.remove('hidden');
} catch (error) {
console.error('handleSkip ERROR:', error);
// Fallback: attempt to force-complete and reveal, or at least enable Next
try {
if (this.game?.forceCompletePassage) {
const fallbackResults = this.game.forceCompletePassage();
this.lastRevealWasSkip = true;
this.displayResults(fallbackResults);
} else {
this.elements.nextBtn.classList.remove('hidden');
}
} catch (fallbackError) {
console.warn('handleSkip fallback failed; showing Next button:', fallbackError);
this.elements.nextBtn.classList.remove('hidden');
}
} finally {
// Reset guard so future rounds can skip normally
this._skipInProgress = false;
}
}
/**
* Send analytics data to backend
*/
async sendAnalytics(passed) {
try {
await this.analytics.completePassage(passed);
} catch (error) {
// Non-critical - don't break gameplay
console.warn('📊 Analytics send failed (non-critical):', error);
}
}
displayResults(results) {
let message = `Score: ${results.correct}/${results.total}`;
if (results.passed) {
// Check if level was just advanced
if (results.justAdvancedLevel) {
message += ` - Level ${results.currentLevel} unlocked!`;
// Check for milestone notification (every 5 levels)
if (results.currentLevel % 5 === 0) {
this.leaderboardUI.showMilestoneNotification(results.currentLevel);
}
// Check for high score
this.checkForHighScore();
} else {
message += ` - Passed!`;
}
this.elements.result.className = 'mt-4 text-center font-semibold text-green-600';
} else {
message += ` - Failed (need ${results.requiredCorrect} correct)`;
this.elements.result.className = 'mt-4 text-center font-semibold text-red-600';
}
this.elements.result.textContent = message;
// Always reveal answers at the end of each round
this.revealAnswersInPlace(results.results);
// Show next button and hide submit/skip buttons
this.elements.submitBtn.style.display = 'none';
if (this.elements.skipBtn) {
this.elements.skipBtn.classList.add('hidden');
}
this.elements.nextBtn.classList.remove('hidden');
// Update streak display after processing results
this.updateStreakDisplay();
}
updateStreakDisplay() {
const stats = this.game.leaderboardService.getPlayerStats();
const currentStreak = stats.currentStreak;
if (currentStreak > 0) {
this.elements.streakInfo.innerHTML = `🔥 ${currentStreak} streak`;
this.elements.streakInfo.classList.remove('hidden');
} else {
this.elements.streakInfo.classList.add('hidden');
}
}
highlightAnswers(results) {
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
results.forEach((result, index) => {
const input = inputs[index];
if (input) {
if (result.isCorrect) {
input.classList.add('correct');
} else {
input.classList.add('incorrect');
// Show correct answer as placeholder or title
input.title = `Correct answer: ${result.correctAnswer}`;
}
input.disabled = true;
}
});
}
async handleNext() {
try {
// Show loading immediately with specific message
this.showLoading(true, 'Loading passages...');
// Clear chat history when starting new round
this.chatUI.clearChatHistory();
// Always show loading for at least 1 second for smooth UX
const startTime = Date.now();
// Load next round
const roundData = await this.game.nextRound();
// Ensure loading is shown for at least half a second
const elapsedTime = Date.now() - startTime;
if (elapsedTime < 500) {
await new Promise(resolve => setTimeout(resolve, 500 - elapsedTime));
}
this.displayRound(roundData);
this.resetUI();
this.showLoading(false);
} catch (error) {
console.error('Error loading next round:', error);
this.showError('Could not load next round. Please try again.');
}
}
// Reveal correct answers immediately after submission
revealAnswersInPlace(results) {
const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
// Remove any previously rendered external labels to avoid duplicates
const existingLabels = this.elements.passageContent.querySelectorAll('.correct-answer-reveal');
existingLabels.forEach(node => node.remove());
results.forEach((result, index) => {
const input = inputs[index];
if (input) {
// Always set the correct answer in the input
input.value = result.correctAnswer;
if (this.lastRevealWasSkip) {
// Neutral reveal on skip: no red/green styling
input.classList.remove('correct', 'incorrect');
input.style.backgroundColor = '';
input.style.borderColor = '';
} else {
// Normal reveal with visual feedback
if (result.isCorrect) {
input.classList.add('correct');
input.classList.remove('incorrect');
input.style.backgroundColor = '#dcfce7'; // Light green
input.style.borderColor = '#16a34a'; // Green border
} else {
input.classList.add('incorrect');
input.classList.remove('correct');
input.style.backgroundColor = '#fef2f2'; // Light red
input.style.borderColor = '#dc2626'; // Red border
}
}
input.disabled = true;
}
});
}
populateHints() {
if (!this.currentHints || this.currentHints.length === 0) {
this.elements.hintsList.innerHTML = '<div class="text-yellow-600">No hints available for this passage.</div>';
return;
}
const hintsHtml = this.currentHints.map((hintData, index) =>
`<div class="flex items-start gap-2">
<span class="font-semibold text-yellow-800">${index + 1}.</span>
<span>${hintData.hint}</span>
</div>`
).join('');
this.elements.hintsList.innerHTML = hintsHtml;
}
toggleHints() {
const isHidden = this.elements.hintsSection.style.display === 'none';
this.elements.hintsSection.style.display = isHidden ? 'block' : 'none';
this.elements.hintBtn.textContent = isHidden ? 'Hide Hints' : 'Show Hints';
}
resetUI() {
this.elements.result.textContent = '';
this.elements.submitBtn.style.display = 'inline-block';
this.elements.submitBtn.disabled = true;
this.elements.submitBtn.textContent = 'Submit'; // Reset button text
if (this.elements.skipBtn) {
this.elements.skipBtn.classList.add('hidden'); // Hide skip button
this.elements.skipBtn.disabled = false; // Re-enable for new round
this.elements.skipBtn.removeAttribute('disabled');
this.elements.skipBtn.setAttribute('aria-disabled', 'false');
}
this.elements.nextBtn.classList.add('hidden');
this.elements.hintsSection.style.display = 'none';
this.elements.hintBtn.textContent = 'Show Hints';
this.currentResults = null;
this.currentHints = [];
this.isRetrying = false; // Reset retry state
this._skipInProgress = false; // Allow skip again in new round
this.lastRevealWasSkip = false;
}
showLoading(show, message = 'Loading passages...') {
if (show) {
this.elements.loading.innerHTML = `
<div class="text-center py-8">
<p class="text-lg loading-text">${message}</p>
</div>
`;
this.elements.loading.classList.remove('hidden');
this.elements.gameArea.classList.add('hidden');
this.elements.stickyControls.classList.add('hidden');
} else {
this.elements.loading.classList.add('hidden');
this.elements.gameArea.classList.remove('hidden');
this.elements.stickyControls.classList.remove('hidden');
}
}
showError(message) {
this.elements.loading.innerHTML = `
<div class="text-center py-8">
<p class="text-lg text-red-600 mb-4">${message}</p>
<button onclick="location.reload()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Reload
</button>
</div>
`;
this.elements.loading.classList.remove('hidden');
this.elements.gameArea.classList.add('hidden');
}
checkForHighScore() {
// Check if current score qualifies for leaderboard
if (this.game.checkForHighScore()) {
const rank = this.game.getHighScoreRank();
const stats = this.game.leaderboardService.getStats();
// Always show initials entry when achieving a high score
// Pre-fills with previous initials if available, allowing changes
this.leaderboardUI.showInitialsEntry(
stats.highestLevel,
stats.roundAtHighestLevel,
rank,
(initials) => {
// Save to leaderboard
const finalRank = this.game.addToLeaderboard(initials);
}
);
}
}
}
// Initialize the app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
const app = new App();
// Show welcome overlay immediately before any loading
app.welcomeOverlay.show();
app.initialize();
// Expose API key setter for browser console
window.setOpenRouterKey = (key) => {
app.game.chatService.aiService.setApiKey(key);
};
});