Update Version (Beta): login + leaderboard
This commit is contained in:
@@ -12,6 +12,10 @@ let isAnimating = false
|
||||
let hourlyRound = null
|
||||
let lockCountdownTimer = null
|
||||
let supabaseClient = null
|
||||
let authSession = null
|
||||
let authProfile = null
|
||||
let leaderboardScope = "hour"
|
||||
let latestLeaderboardRows = []
|
||||
|
||||
const WORD_LENGTH = 5
|
||||
const MAX_GUESSES = 6
|
||||
@@ -66,9 +70,33 @@ const roundStatus = document.getElementById("round-status")
|
||||
const triesStatus = document.getElementById("tries-status")
|
||||
const themeButton = document.getElementById("theme-button")
|
||||
const statsButton = document.getElementById("Stats-button")
|
||||
const leaderboardButton = document.getElementById("leaderboard-button")
|
||||
const leaderboardModal = document.getElementById("leaderboard-modal")
|
||||
const leaderboardList = document.getElementById("leaderboard-list")
|
||||
const leaderboardCountdown = document.getElementById("leaderboard-countdown")
|
||||
const leaderboardTabs = document.querySelectorAll("[data-leaderboard-scope]")
|
||||
const authButton = document.getElementById("auth-button")
|
||||
const authModal = document.getElementById("auth-modal")
|
||||
const authForm = document.getElementById("auth-form")
|
||||
const accountSummary = document.getElementById("account-summary")
|
||||
const authCreateFields = document.getElementById("auth-create-fields")
|
||||
const authUsernameInput = document.getElementById("auth-username")
|
||||
const authEmailInput = document.getElementById("auth-email")
|
||||
const authPasswordInput = document.getElementById("auth-password")
|
||||
const authNewPasswordInput = document.getElementById("auth-new-password")
|
||||
const authSignUpButton = document.getElementById("auth-sign-up")
|
||||
const authSignInButton = document.getElementById("auth-sign-in")
|
||||
const authForgotPasswordButton = document.getElementById("auth-forgot-password")
|
||||
const authUpdatePasswordButton = document.getElementById("auth-update-password")
|
||||
const authResetFields = document.getElementById("auth-reset-fields")
|
||||
const authGuestButton = document.getElementById("auth-guest")
|
||||
const authSignOutButton = document.getElementById("auth-sign-out")
|
||||
const authStatus = document.getElementById("auth-status")
|
||||
const statsModal = document.getElementById("stats-modal")
|
||||
const statsGrid = document.getElementById("stats-grid")
|
||||
const statsNote = document.getElementById("stats-note")
|
||||
const guessBars = document.getElementById("guess-bars")
|
||||
const historyList = document.getElementById("history-list")
|
||||
const resetStatsButton = document.getElementById("reset-stats")
|
||||
const shareResultsButton = document.getElementById("share-results")
|
||||
|
||||
@@ -86,6 +114,7 @@ async function initializeGame() {
|
||||
createKeyboard()
|
||||
restoreTheme()
|
||||
bindControls()
|
||||
await initializeAuth()
|
||||
|
||||
try {
|
||||
await loadWordLists()
|
||||
@@ -167,6 +196,95 @@ function normalizeWords(words) {
|
||||
.filter(word => word.length === WORD_LENGTH)
|
||||
}
|
||||
|
||||
async function initializeAuth() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client) {
|
||||
updateAuthControls()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: { session } } = await client.auth.getSession()
|
||||
authSession = session
|
||||
authProfile = session ? await getAuthProfile() : null
|
||||
updateAuthControls()
|
||||
|
||||
client.auth.onAuthStateChange(async (event, session) => {
|
||||
authSession = session
|
||||
authProfile = session ? await getAuthProfile() : null
|
||||
updateAuthControls()
|
||||
|
||||
if (event === "PASSWORD_RECOVERY") {
|
||||
openAuth()
|
||||
setPasswordResetMode(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (event === "SIGNED_IN") window.location.reload()
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Supabase auth unavailable:", error)
|
||||
updateAuthControls()
|
||||
}
|
||||
}
|
||||
|
||||
async function getAuthProfile() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client || !authSession) return null
|
||||
|
||||
const { data, error } = await client
|
||||
.from("profiles")
|
||||
.select("username, display_name")
|
||||
.eq("id", authSession.user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
console.warn("Failed to load profile:", error)
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
function updateAuthControls() {
|
||||
const hasSupabase = Boolean(getSupabaseClient())
|
||||
const label = getAuthLabel()
|
||||
|
||||
if (authButton) {
|
||||
authButton.textContent = label
|
||||
authButton.title = authSession ? "Account" : "Sign in"
|
||||
authButton.setAttribute("aria-label", authSession ? "Open account" : "Sign in to sync stats")
|
||||
}
|
||||
|
||||
if (authStatus) {
|
||||
authStatus.textContent = hasSupabase
|
||||
? authSession
|
||||
? `Signed in as ${label}`
|
||||
: "Sign in with your email and password. If we cannot find your account, you can create one here."
|
||||
: "Supabase is not configured. Guest play is active."
|
||||
}
|
||||
|
||||
if (accountSummary) {
|
||||
accountSummary.hidden = !authSession
|
||||
accountSummary.innerHTML = authSession
|
||||
? `<strong>${escapeHtml(label)}</strong><span>${escapeHtml(authSession.user.email || "Signed in")}</span>`
|
||||
: ""
|
||||
}
|
||||
|
||||
if (authForm) authForm.hidden = !hasSupabase || Boolean(authSession)
|
||||
if (authGuestButton) authGuestButton.hidden = Boolean(authSession)
|
||||
if (authSignOutButton) authSignOutButton.hidden = !authSession
|
||||
}
|
||||
|
||||
function getAuthLabel() {
|
||||
if (!authSession) return "Sign in"
|
||||
|
||||
return authProfile?.username
|
||||
|| authProfile?.display_name
|
||||
|| authSession.user.email
|
||||
|| "Account"
|
||||
}
|
||||
|
||||
async function startHourlyRound() {
|
||||
const selectedWord = getCurrentHourlyWord()
|
||||
const remoteRound = await startRemoteHourlyRound()
|
||||
@@ -177,10 +295,9 @@ async function startHourlyRound() {
|
||||
|
||||
async function startRemoteHourlyRound() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client) return null
|
||||
if (!client || !authSession) return null
|
||||
|
||||
try {
|
||||
await ensureSupabaseSession(client)
|
||||
const { data, error } = await client.rpc("start_hourly_round")
|
||||
if (error) throw error
|
||||
|
||||
@@ -206,29 +323,32 @@ function getSupabaseClient() {
|
||||
return supabaseClient
|
||||
}
|
||||
|
||||
async function ensureSupabaseSession(client) {
|
||||
const { data: { session } } = await client.auth.getSession()
|
||||
if (session) return
|
||||
|
||||
const { error } = await client.auth.signInAnonymously()
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
function normalizeRemoteRound(row) {
|
||||
return {
|
||||
id: row.round_id,
|
||||
backend: "supabase",
|
||||
word: row.word,
|
||||
word: row.revealed_word || "",
|
||||
hourStart: Date.parse(row.hour_start),
|
||||
startedAt: Date.parse(row.started_at),
|
||||
nextPlayableAt: Date.parse(row.next_playable_at),
|
||||
completedAt: row.completed_at ? Date.parse(row.completed_at) : null,
|
||||
won: row.won,
|
||||
guessCount: row.guess_count,
|
||||
isExisting: row.is_existing
|
||||
isExisting: row.is_existing,
|
||||
guesses: normalizeRemoteGuesses(row.guesses)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRemoteGuesses(guesses) {
|
||||
if (!Array.isArray(guesses)) return []
|
||||
|
||||
return guesses.map(guess => ({
|
||||
guess: guess.guess,
|
||||
rowIndex: guess.rowIndex,
|
||||
states: guess.states
|
||||
}))
|
||||
}
|
||||
|
||||
function startLocalHourlyRound(selectedWord) {
|
||||
const savedRound = getLocalHourlyRound()
|
||||
if (savedRound && savedRound.nextPlayableAt > Date.now()) return savedRound
|
||||
@@ -287,16 +407,17 @@ function getCurrentHourStart(timestamp) {
|
||||
|
||||
function applyHourlyRound(round) {
|
||||
hourlyRound = round
|
||||
targetWord = round.word
|
||||
dictionarySet.add(targetWord)
|
||||
currentGuessIndex = round.completedAt ? round.guessCount || 0 : 0
|
||||
targetWord = round.word || ""
|
||||
if (targetWord) dictionarySet.add(targetWord)
|
||||
restoreGuesses(round.guesses || [])
|
||||
currentGuessIndex = round.completedAt ? round.guessCount || currentGuessIndex : currentGuessIndex
|
||||
updateTriesStatus()
|
||||
|
||||
if (round.completedAt) {
|
||||
lastResult = {
|
||||
won: Boolean(round.won),
|
||||
guesses: round.guessCount || MAX_GUESSES,
|
||||
word: round.word
|
||||
word: round.word || ""
|
||||
}
|
||||
lockUntilNextWord()
|
||||
return
|
||||
@@ -306,6 +427,33 @@ function applyHourlyRound(round) {
|
||||
startInteraction()
|
||||
}
|
||||
|
||||
function restoreGuesses(guesses) {
|
||||
if (!Array.isArray(guesses) || guesses.length === 0) {
|
||||
currentGuessIndex = 0
|
||||
return
|
||||
}
|
||||
|
||||
guesses.forEach(savedGuess => {
|
||||
const rowIndex = Number(savedGuess.rowIndex) || 0
|
||||
const rowStart = (rowIndex - 1) * WORD_LENGTH
|
||||
const states = Array.isArray(savedGuess.states) ? savedGuess.states : []
|
||||
|
||||
String(savedGuess.guess || "").split("").forEach((letter, index) => {
|
||||
const tile = guessGrid.children[rowStart + index]
|
||||
if (!tile) return
|
||||
|
||||
const state = states[index] || "wrong"
|
||||
tile.dataset.letter = letter
|
||||
tile.textContent = letter
|
||||
tile.dataset.state = state
|
||||
tile.setAttribute("aria-label", `${letter}, ${readableState(state)}`)
|
||||
updateKeyState(keyboard.querySelector(`[data-key="${letter}"i]`), state)
|
||||
})
|
||||
})
|
||||
|
||||
currentGuessIndex = Math.min(guesses.length, MAX_GUESSES)
|
||||
}
|
||||
|
||||
function createBoard() {
|
||||
guessGrid.innerHTML = ""
|
||||
|
||||
@@ -364,6 +512,20 @@ function createActionKey(label, ariaLabel, attribute) {
|
||||
function bindControls() {
|
||||
themeButton.addEventListener("click", toggleTheme)
|
||||
statsButton.addEventListener("click", openStats)
|
||||
leaderboardButton?.addEventListener("click", openLeaderboard)
|
||||
leaderboardTabs.forEach(tab => {
|
||||
tab.addEventListener("click", () => switchLeaderboardScope(tab.dataset.leaderboardScope))
|
||||
})
|
||||
authButton?.addEventListener("click", openAuth)
|
||||
authSignUpButton?.addEventListener("click", signUpWithPassword)
|
||||
authSignInButton?.addEventListener("click", signInWithPassword)
|
||||
authForgotPasswordButton?.addEventListener("click", sendPasswordReset)
|
||||
authUpdatePasswordButton?.addEventListener("click", updatePassword)
|
||||
authForm?.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter") signInWithPassword(event)
|
||||
})
|
||||
authGuestButton?.addEventListener("click", () => authModal.close())
|
||||
authSignOutButton?.addEventListener("click", signOut)
|
||||
resetStatsButton.addEventListener("click", resetStats)
|
||||
shareResultsButton.addEventListener("click", shareResult)
|
||||
document.querySelector(".brand").addEventListener("click", event => {
|
||||
@@ -372,6 +534,176 @@ function bindControls() {
|
||||
})
|
||||
}
|
||||
|
||||
function openAuth() {
|
||||
updateAuthControls()
|
||||
if (!authSession) {
|
||||
setCreateAccountMode(false)
|
||||
setPasswordResetMode(false)
|
||||
}
|
||||
authModal?.showModal()
|
||||
}
|
||||
|
||||
async function signUpWithPassword(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const client = getSupabaseClient()
|
||||
const email = authEmailInput?.value.trim()
|
||||
const password = authPasswordInput?.value
|
||||
const username = normalizeUsername(authUsernameInput?.value)
|
||||
if (!client || !email || !password || !username) {
|
||||
authStatus.textContent = "Username, email, and password are required."
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
authStatus.textContent = "Password must be at least 6 characters."
|
||||
return
|
||||
}
|
||||
|
||||
const isAvailable = await isUsernameAvailable(username)
|
||||
if (!isAvailable) {
|
||||
authStatus.textContent = "That username is already taken. Try another one."
|
||||
return
|
||||
}
|
||||
|
||||
authStatus.textContent = "Creating account..."
|
||||
const { error } = await client.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
username,
|
||||
display_name: username
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
authStatus.textContent = error
|
||||
? readableAuthError(error)
|
||||
: "Account created. Check your email if confirmation is required."
|
||||
}
|
||||
|
||||
async function signInWithPassword(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const client = getSupabaseClient()
|
||||
const email = authEmailInput?.value.trim()
|
||||
const password = authPasswordInput?.value
|
||||
if (!client || !email || !password) {
|
||||
authStatus.textContent = "Email and password are required."
|
||||
return
|
||||
}
|
||||
|
||||
authStatus.textContent = "Signing in..."
|
||||
const { error } = await client.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setCreateAccountMode(true)
|
||||
authStatus.textContent = `${readableAuthError(error)} Create an account below if this is your first time.`
|
||||
}
|
||||
}
|
||||
|
||||
async function isUsernameAvailable(username) {
|
||||
const client = getSupabaseClient()
|
||||
if (!client) return false
|
||||
|
||||
const { data, error } = await client.rpc("is_username_available", {
|
||||
candidate_username: username
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.warn("Failed to check username:", error)
|
||||
return false
|
||||
}
|
||||
|
||||
return Boolean(data)
|
||||
}
|
||||
|
||||
async function sendPasswordReset() {
|
||||
const client = getSupabaseClient()
|
||||
const email = authEmailInput?.value.trim()
|
||||
if (!client || !email) {
|
||||
authStatus.textContent = "Enter your email first, then request a reset link."
|
||||
return
|
||||
}
|
||||
|
||||
authStatus.textContent = "Sending password reset..."
|
||||
const { error } = await client.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: window.location.href
|
||||
})
|
||||
|
||||
authStatus.textContent = error
|
||||
? readableAuthError(error)
|
||||
: "Password reset link sent. Check your email."
|
||||
}
|
||||
|
||||
async function updatePassword() {
|
||||
const client = getSupabaseClient()
|
||||
const password = authNewPasswordInput?.value
|
||||
if (!client || !password || password.length < 6) {
|
||||
authStatus.textContent = "New password must be at least 6 characters."
|
||||
return
|
||||
}
|
||||
|
||||
const { error } = await client.auth.updateUser({ password })
|
||||
authStatus.textContent = error
|
||||
? readableAuthError(error)
|
||||
: "Password updated. You can keep playing."
|
||||
if (!error) setPasswordResetMode(false)
|
||||
}
|
||||
|
||||
function setCreateAccountMode(isVisible) {
|
||||
if (authCreateFields) authCreateFields.hidden = !isVisible
|
||||
if (authSignUpButton) authSignUpButton.hidden = !isVisible
|
||||
if (authStatus && isVisible) {
|
||||
authStatus.textContent = "New here? Choose a username, keep the same email/password, and create your account."
|
||||
}
|
||||
}
|
||||
|
||||
function setPasswordResetMode(isVisible) {
|
||||
if (authResetFields) authResetFields.hidden = !isVisible
|
||||
if (authForgotPasswordButton) authForgotPasswordButton.hidden = isVisible
|
||||
if (authStatus && isVisible) authStatus.textContent = "Choose a new password for your account."
|
||||
}
|
||||
|
||||
function readableAuthError(error) {
|
||||
const message = String(error?.message || "Authentication failed.")
|
||||
const lowerMessage = message.toLowerCase()
|
||||
|
||||
if (lowerMessage.includes("invalid login") || lowerMessage.includes("invalid credentials")) {
|
||||
return "Email or password is incorrect."
|
||||
}
|
||||
if (lowerMessage.includes("already registered") || lowerMessage.includes("already exists")) {
|
||||
return "That email already has an account. Sign in instead."
|
||||
}
|
||||
if (lowerMessage.includes("password")) {
|
||||
return "Password must be at least 6 characters."
|
||||
}
|
||||
if (lowerMessage.includes("rate limit")) {
|
||||
return "Too many attempts. Wait a few minutes and try again."
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
function normalizeUsername(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, "")
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client) return
|
||||
|
||||
await client.auth.signOut()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function startInteraction() {
|
||||
stopInteraction()
|
||||
document.addEventListener("click", handleMouseClick)
|
||||
@@ -401,7 +733,7 @@ function handleMouseClick(event) {
|
||||
}
|
||||
|
||||
function handleKeyPress(event) {
|
||||
if (statsModal.open) return
|
||||
if (statsModal.open || authModal?.open) return
|
||||
|
||||
if (event.key === "Enter") {
|
||||
submitGuess()
|
||||
@@ -449,7 +781,7 @@ function deleteKey() {
|
||||
lastTile.setAttribute("aria-label", "Empty letter")
|
||||
}
|
||||
|
||||
function submitGuess() {
|
||||
async function submitGuess() {
|
||||
if (gameFinished || isAnimating) return
|
||||
|
||||
const activeTiles = getCurrentRowTiles().filter(tile => tile.dataset.letter)
|
||||
@@ -469,15 +801,93 @@ function submitGuess() {
|
||||
stopInteraction()
|
||||
isAnimating = true
|
||||
roundStatus.textContent = "Checking guess…"
|
||||
currentGuessIndex += 1
|
||||
|
||||
const result = await resolveGuess(guess, activeTiles)
|
||||
if (!result) return
|
||||
|
||||
const states = normalizeGuessStates(result.states)
|
||||
if (states.length !== WORD_LENGTH) {
|
||||
recoverFromGuessError(activeTiles, "Guess could not be scored")
|
||||
return
|
||||
}
|
||||
|
||||
currentGuessIndex = result.rowIndex
|
||||
updateTriesStatus()
|
||||
|
||||
const states = scoreGuess(guess, targetWord)
|
||||
activeTiles.forEach((tile, index, tiles) => {
|
||||
flipTile(tile, index, tiles, guess, states[index])
|
||||
flipTile(tile, index, tiles, guess, states[index], { ...result, states })
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveGuess(guess, activeTiles) {
|
||||
if (hourlyRound?.backend === "supabase") {
|
||||
try {
|
||||
return await submitRemoteGuess(guess)
|
||||
} catch (error) {
|
||||
console.warn("Failed to submit guess:", error)
|
||||
recoverFromGuessError(activeTiles, error.message || "Guess could not be saved")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const rowIndex = currentGuessIndex + 1
|
||||
return {
|
||||
rowIndex,
|
||||
states: scoreGuess(guess, targetWord),
|
||||
completed: guess === targetWord || rowIndex === MAX_GUESSES,
|
||||
won: guess === targetWord,
|
||||
revealedWord: targetWord
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRemoteGuess(guess) {
|
||||
const client = getSupabaseClient()
|
||||
if (!client || !hourlyRound?.id) throw new Error("Sign in again to save this guess")
|
||||
|
||||
const { data, error } = await client.rpc("submit_guess", {
|
||||
target_round_id: hourlyRound.id,
|
||||
submitted_guess: guess
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const row = Array.isArray(data) ? data[0] : data
|
||||
if (!row) throw new Error("No result returned")
|
||||
|
||||
return {
|
||||
rowIndex: row.row_index,
|
||||
states: normalizeGuessStates(row.states),
|
||||
completed: row.completed,
|
||||
won: row.won,
|
||||
guessCount: row.guess_count,
|
||||
revealedWord: row.revealed_word,
|
||||
nextPlayableAt: Date.parse(row.next_playable_at)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGuessStates(states) {
|
||||
if (Array.isArray(states)) return states
|
||||
|
||||
if (typeof states === "string") {
|
||||
try {
|
||||
const parsedStates = JSON.parse(states)
|
||||
return Array.isArray(parsedStates) ? parsedStates : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function recoverFromGuessError(activeTiles, message) {
|
||||
showAlert(message)
|
||||
shakeTiles(activeTiles)
|
||||
roundStatus.textContent = "Try again"
|
||||
isAnimating = false
|
||||
startInteraction()
|
||||
}
|
||||
|
||||
function isValidGuess(guess) {
|
||||
if (!/^[a-z]{5}$/.test(guess)) return false
|
||||
return dictionarySet.has(guess)
|
||||
@@ -507,7 +917,7 @@ function scoreGuess(guess, answer) {
|
||||
return states
|
||||
}
|
||||
|
||||
function flipTile(tile, index, tiles, guess, state) {
|
||||
function flipTile(tile, index, tiles, guess, state, roundResult) {
|
||||
const letter = tile.dataset.letter
|
||||
const key = keyboard.querySelector(`[data-key="${letter}"i]`)
|
||||
|
||||
@@ -525,7 +935,7 @@ function flipTile(tile, index, tiles, guess, state) {
|
||||
setTimeout(() => {
|
||||
currentTileIndex = 0
|
||||
isAnimating = false
|
||||
checkWinLose(guess, tiles)
|
||||
checkWinLose(guess, tiles, roundResult)
|
||||
}, FLIP_ANIMATION_DURATION / 2)
|
||||
}
|
||||
}, FLIP_ANIMATION_DURATION / 2)
|
||||
@@ -583,8 +993,21 @@ function shakeTiles(tiles) {
|
||||
})
|
||||
}
|
||||
|
||||
function checkWinLose(guess, tiles) {
|
||||
if (guess === targetWord) {
|
||||
function checkWinLose(guess, tiles, roundResult) {
|
||||
if (roundResult?.revealedWord) {
|
||||
targetWord = roundResult.revealedWord
|
||||
hourlyRound = {
|
||||
...hourlyRound,
|
||||
word: targetWord,
|
||||
completedAt: roundResult.completed ? Date.now() : null,
|
||||
won: roundResult.won,
|
||||
guessCount: roundResult.guessCount || currentGuessIndex,
|
||||
nextPlayableAt: roundResult.nextPlayableAt || hourlyRound.nextPlayableAt
|
||||
}
|
||||
}
|
||||
|
||||
const wonRound = roundResult ? roundResult.completed && roundResult.won : guess === targetWord
|
||||
if (wonRound) {
|
||||
gameFinished = true
|
||||
lastResult = { won: true, guesses: currentGuessIndex, word: targetWord }
|
||||
saveGameResult(lastResult)
|
||||
@@ -595,12 +1018,14 @@ function checkWinLose(guess, tiles) {
|
||||
roundStatus.textContent = `Solved in ${currentGuessIndex}`
|
||||
setTimeout(() => showWordDefinition(targetWord, true), 1800)
|
||||
startLockCountdown(2400)
|
||||
showLeaderboardAfterCompletion()
|
||||
stopInteraction()
|
||||
return
|
||||
}
|
||||
|
||||
const remainingTiles = guessGrid.querySelectorAll(":not([data-letter])")
|
||||
if (remainingTiles.length === 0) {
|
||||
const lostRound = roundResult ? roundResult.completed && !roundResult.won : remainingTiles.length === 0
|
||||
if (lostRound) {
|
||||
gameFinished = true
|
||||
lastResult = { won: false, guesses: MAX_GUESSES, word: targetWord }
|
||||
saveGameResult(lastResult)
|
||||
@@ -608,6 +1033,7 @@ function checkWinLose(guess, tiles) {
|
||||
roundStatus.textContent = "Round complete"
|
||||
showWordDefinition(targetWord, false)
|
||||
startLockCountdown(2400)
|
||||
showLeaderboardAfterCompletion()
|
||||
stopInteraction()
|
||||
return
|
||||
}
|
||||
@@ -620,6 +1046,17 @@ function updateTriesStatus() {
|
||||
triesStatus.textContent = `${currentGuessIndex} / ${MAX_GUESSES}`
|
||||
}
|
||||
|
||||
function showLeaderboardAfterCompletion() {
|
||||
setTimeout(() => {
|
||||
if (statsModal.open || authModal?.open) return
|
||||
leaderboardScope = "hour"
|
||||
leaderboardTabs.forEach(tab => {
|
||||
tab.classList.toggle("active", tab.dataset.leaderboardScope === leaderboardScope)
|
||||
})
|
||||
openLeaderboard()
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
function lockUntilNextWord() {
|
||||
gameFinished = true
|
||||
stopInteraction()
|
||||
@@ -665,6 +1102,7 @@ function updateLockCountdown() {
|
||||
|
||||
roundStatus.textContent = `Next word in ${formatRemainingTime(remaining)}`
|
||||
triesStatus.textContent = "Locked"
|
||||
updateLeaderboardCountdown()
|
||||
}
|
||||
|
||||
function formatRemainingTime(milliseconds) {
|
||||
@@ -773,11 +1211,26 @@ function toggleTheme() {
|
||||
themeButton.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode")
|
||||
}
|
||||
|
||||
function openStats() {
|
||||
renderStats()
|
||||
async function openStats() {
|
||||
await renderStats()
|
||||
statsModal.showModal()
|
||||
}
|
||||
|
||||
async function openLeaderboard() {
|
||||
renderLeaderboardLoading()
|
||||
leaderboardModal?.showModal()
|
||||
await renderLeaderboard()
|
||||
}
|
||||
|
||||
function switchLeaderboardScope(scope) {
|
||||
leaderboardScope = scope || "hour"
|
||||
leaderboardTabs.forEach(tab => {
|
||||
tab.classList.toggle("active", tab.dataset.leaderboardScope === leaderboardScope)
|
||||
})
|
||||
renderLeaderboardLoading()
|
||||
renderLeaderboard()
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const fallback = {
|
||||
played: 0,
|
||||
@@ -799,6 +1252,8 @@ function saveStats(stats) {
|
||||
}
|
||||
|
||||
function saveGameResult(result) {
|
||||
if (hourlyRound?.backend === "supabase") return
|
||||
|
||||
const stats = getStats()
|
||||
stats.played += 1
|
||||
|
||||
@@ -812,10 +1267,6 @@ function saveGameResult(result) {
|
||||
}
|
||||
|
||||
saveStats(stats)
|
||||
completeHourlyRound(result)
|
||||
}
|
||||
|
||||
function completeHourlyRound(result) {
|
||||
if (!hourlyRound || hourlyRound.completedAt) return
|
||||
|
||||
hourlyRound = {
|
||||
@@ -825,31 +1276,12 @@ function completeHourlyRound(result) {
|
||||
guessCount: result.guesses
|
||||
}
|
||||
|
||||
if (hourlyRound.backend === "local") {
|
||||
saveLocalHourlyRound(hourlyRound)
|
||||
return
|
||||
}
|
||||
|
||||
completeRemoteHourlyRound(result).catch(error => {
|
||||
console.warn("Failed to complete Supabase hourly round:", error)
|
||||
})
|
||||
saveLocalHourlyRound(hourlyRound)
|
||||
}
|
||||
|
||||
async function completeRemoteHourlyRound(result) {
|
||||
const client = getSupabaseClient()
|
||||
if (!client || !hourlyRound?.id) return
|
||||
|
||||
const { error } = await client.rpc("complete_hourly_round", {
|
||||
round_id: hourlyRound.id,
|
||||
did_win: result.won,
|
||||
guess_total: result.guesses
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const stats = getStats()
|
||||
async function renderStats() {
|
||||
const stats = await getDisplayStats()
|
||||
const summary = await getHourlySummary()
|
||||
const winRate = stats.played === 0 ? 0 : Math.round((stats.wins / stats.played) * 100)
|
||||
const statItems = [
|
||||
["Played", stats.played],
|
||||
@@ -874,16 +1306,237 @@ function renderStats() {
|
||||
`
|
||||
})
|
||||
.join("")
|
||||
|
||||
renderStatsNote(summary)
|
||||
await renderHistory()
|
||||
}
|
||||
|
||||
function resetStats() {
|
||||
function renderStatsNote(summary) {
|
||||
if (!statsNote) return
|
||||
|
||||
if (!summary || summary.completedCount === 0) {
|
||||
statsNote.textContent = "No completed scores yet this hour."
|
||||
return
|
||||
}
|
||||
|
||||
const average = summary.averageGuesses ? Number(summary.averageGuesses).toFixed(2) : "--"
|
||||
statsNote.textContent = `This hour: ${summary.completedCount} finishers · ${summary.winRate}% win rate · ${average} average guesses.`
|
||||
}
|
||||
|
||||
async function getDisplayStats() {
|
||||
if (hourlyRound?.backend !== "supabase") return getStats()
|
||||
|
||||
const remoteStats = await getRemoteStats()
|
||||
return remoteStats || getStats()
|
||||
}
|
||||
|
||||
async function getRemoteStats() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client || !authSession) return null
|
||||
|
||||
const { data, error } = await client.rpc("get_user_stats")
|
||||
if (error) {
|
||||
console.warn("Failed to load synced stats:", error)
|
||||
return null
|
||||
}
|
||||
|
||||
const row = Array.isArray(data) ? data[0] : data
|
||||
if (!row) return null
|
||||
|
||||
return {
|
||||
played: row.played || 0,
|
||||
wins: row.wins || 0,
|
||||
currentStreak: row.current_streak || 0,
|
||||
maxStreak: row.max_streak || 0,
|
||||
distribution: Array.isArray(row.distribution) ? row.distribution : [0, 0, 0, 0, 0, 0]
|
||||
}
|
||||
}
|
||||
|
||||
async function getHourlySummary() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client) return null
|
||||
|
||||
const { data, error } = await client.rpc("get_hourly_summary")
|
||||
if (error) {
|
||||
console.warn("Failed to load hourly summary:", error)
|
||||
return null
|
||||
}
|
||||
|
||||
const row = Array.isArray(data) ? data[0] : data
|
||||
if (!row) return null
|
||||
|
||||
return {
|
||||
completedCount: row.completed_count || 0,
|
||||
winRate: row.win_rate || 0,
|
||||
averageGuesses: row.average_guesses
|
||||
}
|
||||
}
|
||||
|
||||
async function renderHistory() {
|
||||
if (!historyList) return
|
||||
|
||||
if (hourlyRound?.backend !== "supabase") {
|
||||
historyList.innerHTML = '<div class="leaderboard-empty">Sign in to keep a cross-device history.</div>'
|
||||
return
|
||||
}
|
||||
|
||||
const rows = await getPlayerHistory()
|
||||
if (rows.length === 0) {
|
||||
historyList.innerHTML = '<div class="leaderboard-empty">No completed rounds yet.</div>'
|
||||
return
|
||||
}
|
||||
|
||||
historyList.innerHTML = rows
|
||||
.map(row => `
|
||||
<div class="history-row">
|
||||
<span>
|
||||
<span class="history-word">${escapeHtml(row.word)}</span>
|
||||
<span class="history-meta">${formatHistoryHour(row.hourStart)}</span>
|
||||
</span>
|
||||
<span class="history-meta">${row.won ? `${row.guessCount}/6` : "X/6"}</span>
|
||||
</div>
|
||||
`)
|
||||
.join("")
|
||||
}
|
||||
|
||||
async function getPlayerHistory() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client || !authSession) return []
|
||||
|
||||
const { data, error } = await client.rpc("get_player_history", { history_limit: 8 })
|
||||
if (error) {
|
||||
console.warn("Failed to load history:", error)
|
||||
return []
|
||||
}
|
||||
|
||||
return (Array.isArray(data) ? data : []).map(row => ({
|
||||
hourStart: row.hour_start,
|
||||
word: row.word || "-----",
|
||||
won: Boolean(row.won),
|
||||
guessCount: row.guess_count || MAX_GUESSES,
|
||||
completedAt: row.completed_at
|
||||
}))
|
||||
}
|
||||
|
||||
function formatHistoryHour(value) {
|
||||
if (!value) return ""
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric"
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function renderLeaderboardLoading() {
|
||||
if (!leaderboardList) return
|
||||
|
||||
leaderboardList.innerHTML = '<div class="leaderboard-empty">Loading this hour\'s finishers...</div>'
|
||||
updateLeaderboardCountdown()
|
||||
}
|
||||
|
||||
async function renderLeaderboard() {
|
||||
if (!leaderboardList) return
|
||||
|
||||
const rows = await getLeaderboardRows(leaderboardScope)
|
||||
latestLeaderboardRows = rows
|
||||
updateLeaderboardCountdown()
|
||||
if (rows.length === 0) {
|
||||
leaderboardList.innerHTML = '<div class="leaderboard-empty">No finishers for this board yet. Be the first to land on it.</div>'
|
||||
return
|
||||
}
|
||||
|
||||
leaderboardList.innerHTML = rows
|
||||
.map(row => `
|
||||
<div class="leaderboard-row ${row.isCurrentUser ? "you" : ""}">
|
||||
<span class="leaderboard-rank">${row.rank}</span>
|
||||
<span class="leaderboard-name">
|
||||
${escapeHtml(row.username)}${row.isCurrentUser ? " (you)" : ""}
|
||||
<span class="leaderboard-streak">${row.currentStreak} streak</span>
|
||||
</span>
|
||||
<span class="leaderboard-score">${formatLeaderboardScore(row)}</span>
|
||||
</div>
|
||||
`)
|
||||
.join("")
|
||||
}
|
||||
|
||||
async function getLeaderboardRows(scope) {
|
||||
const client = getSupabaseClient()
|
||||
if (!client) return []
|
||||
|
||||
const { data, error } = await client.rpc("get_leaderboard", { board_scope: scope })
|
||||
if (error) {
|
||||
console.warn("Failed to load leaderboard:", error)
|
||||
showAlert("Leaderboard unavailable")
|
||||
return []
|
||||
}
|
||||
|
||||
return (Array.isArray(data) ? data : []).map(row => ({
|
||||
username: row.username || "player",
|
||||
won: Boolean(row.won),
|
||||
guessCount: row.guess_count || MAX_GUESSES,
|
||||
completedAt: row.completed_at,
|
||||
rank: row.rank,
|
||||
currentStreak: row.current_streak || 0,
|
||||
isCurrentUser: Boolean(row.is_current_user),
|
||||
wins: row.wins || 0,
|
||||
played: row.played || 0,
|
||||
averageGuesses: row.average_guesses
|
||||
}))
|
||||
}
|
||||
|
||||
function formatLeaderboardScore(row) {
|
||||
if (leaderboardScope === "hour") {
|
||||
return `${row.won ? `${row.guessCount}/6` : "X/6"} · ${formatCompletedTime(row.completedAt)}`
|
||||
}
|
||||
|
||||
const average = row.averageGuesses ? Number(row.averageGuesses).toFixed(2) : "--"
|
||||
return `${row.wins} wins · avg ${average}`
|
||||
}
|
||||
|
||||
function updateLeaderboardCountdown() {
|
||||
if (!leaderboardCountdown) return
|
||||
|
||||
if (leaderboardScope !== "hour") {
|
||||
leaderboardCountdown.textContent = leaderboardScope === "today"
|
||||
? "Today ranks completed rounds since local midnight in the database timezone."
|
||||
: "All time ranks total wins, streaks, and average guesses."
|
||||
return
|
||||
}
|
||||
|
||||
if (!hourlyRound?.nextPlayableAt) {
|
||||
leaderboardCountdown.textContent = "Next word unlocks at the top of the hour."
|
||||
return
|
||||
}
|
||||
|
||||
const remaining = hourlyRound.nextPlayableAt - Date.now()
|
||||
leaderboardCountdown.textContent = remaining > 0
|
||||
? `Next word unlocks in ${formatRemainingTime(remaining)}.`
|
||||
: "The next hourly word is available."
|
||||
}
|
||||
|
||||
function formatCompletedTime(value) {
|
||||
if (!value) return "--:--"
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
async function resetStats() {
|
||||
if (hourlyRound?.backend === "supabase") {
|
||||
showAlert("Synced stats are saved to your account")
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.removeItem(STATS_KEY)
|
||||
renderStats()
|
||||
await renderStats()
|
||||
showAlert("Stats reset")
|
||||
}
|
||||
|
||||
async function shareResult() {
|
||||
const text = buildShareText()
|
||||
const text = await buildShareText()
|
||||
|
||||
try {
|
||||
if (navigator.share) {
|
||||
@@ -899,13 +1552,27 @@ async function shareResult() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildShareText() {
|
||||
async function buildShareText() {
|
||||
const status = lastResult
|
||||
? `${lastResult.won ? lastResult.guesses : "X"}/${MAX_GUESSES}`
|
||||
: `${currentGuessIndex}/${MAX_GUESSES}`
|
||||
const roundLabel = getShareRoundLabel()
|
||||
const rankLabel = await getShareRankLabel()
|
||||
|
||||
return `Fancy Wordle ${roundLabel} ${status}\n${getResultGrid()}`
|
||||
return `Fancy Wordle ${roundLabel} ${status}${rankLabel}\n${getResultGrid()}`
|
||||
}
|
||||
|
||||
async function getShareRankLabel() {
|
||||
if (hourlyRound?.backend !== "supabase" || !lastResult) return ""
|
||||
|
||||
let currentUserRow = leaderboardScope === "hour"
|
||||
? latestLeaderboardRows.find(row => row.isCurrentUser)
|
||||
: null
|
||||
if (!currentUserRow) {
|
||||
currentUserRow = (await getLeaderboardRows("hour")).find(row => row.isCurrentUser)
|
||||
}
|
||||
|
||||
return currentUserRow ? ` · #${currentUserRow.rank} this hour` : ""
|
||||
}
|
||||
|
||||
function getShareRoundLabel() {
|
||||
|
||||
Reference in New Issue
Block a user