1606 lines
45 KiB
JavaScript
1606 lines
45 KiB
JavaScript
let targetWords = []
|
|
let dictionary = []
|
|
let dictionarySet = new Set()
|
|
let targetWord = ""
|
|
let gameFinished = false
|
|
let currentGuessIndex = 0
|
|
let currentTileIndex = 0
|
|
let lastResult = null
|
|
let hasInitialized = false
|
|
let wordListMode = "loading"
|
|
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
|
|
const FLIP_ANIMATION_DURATION = 500
|
|
const DANCE_ANIMATION_DURATION = 500
|
|
const STATS_KEY = "fancy-wordle-stats-v2"
|
|
const LOCAL_ROUND_KEY = "fancy-wordle-hourly-round-v1"
|
|
const PLAY_INTERVAL_MS = 60 * 60 * 1000
|
|
const KEY_ROWS = ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"]
|
|
const FALLBACK_TARGET_WORDS = [
|
|
"about",
|
|
"after",
|
|
"apple",
|
|
"beach",
|
|
"brain",
|
|
"chair",
|
|
"charm",
|
|
"dream",
|
|
"field",
|
|
"flame",
|
|
"grape",
|
|
"heart",
|
|
"house",
|
|
"light",
|
|
"magic",
|
|
"music",
|
|
"plant",
|
|
"river",
|
|
"smile",
|
|
"stone",
|
|
"table",
|
|
"world"
|
|
]
|
|
const FALLBACK_EXTRA_DICTIONARY = [
|
|
"adieu",
|
|
"audio",
|
|
"crane",
|
|
"later",
|
|
"least",
|
|
"raise",
|
|
"roast",
|
|
"slate",
|
|
"stare",
|
|
"tears",
|
|
"trace"
|
|
]
|
|
|
|
const keyboard = document.querySelector("[data-keyboard]")
|
|
const alertContainer = document.querySelector("[data-alert-container]")
|
|
const guessGrid = document.querySelector("[data-guess-grid]")
|
|
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")
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", initializeGame, { once: true })
|
|
} else {
|
|
initializeGame()
|
|
}
|
|
|
|
async function initializeGame() {
|
|
if (hasInitialized) return
|
|
hasInitialized = true
|
|
|
|
createBoard()
|
|
createKeyboard()
|
|
restoreTheme()
|
|
bindControls()
|
|
await initializeAuth()
|
|
|
|
try {
|
|
await loadWordLists()
|
|
roundStatus.textContent = wordListMode === "full" ? "Ready" : "Answer list loaded"
|
|
} catch (error) {
|
|
console.error("Failed to initialize game:", error)
|
|
useFallbackWordLists()
|
|
roundStatus.textContent = "Offline word set"
|
|
showAlert("Using a compact offline word set", 3000)
|
|
}
|
|
|
|
await startHourlyRound()
|
|
}
|
|
|
|
async function loadWordLists() {
|
|
targetWords = await loadWordList("targetWords")
|
|
|
|
if (targetWords.length === 0) {
|
|
throw new Error("targetWords.json is empty or invalid")
|
|
}
|
|
|
|
let dictionaryWords = []
|
|
try {
|
|
dictionaryWords = await loadWordList("dictionary")
|
|
} catch (error) {
|
|
console.warn("dictionary.json unavailable; accepting target words only.", error)
|
|
}
|
|
|
|
dictionary = normalizeWords([
|
|
...dictionaryWords,
|
|
...targetWords
|
|
])
|
|
dictionarySet = new Set(dictionary)
|
|
wordListMode = dictionaryWords.length > 0 ? "full" : "answers-only"
|
|
|
|
if (dictionarySet.size === 0) {
|
|
throw new Error("Accepted word list is empty or invalid")
|
|
}
|
|
}
|
|
|
|
async function loadWordList(listName) {
|
|
const fileName = listName === "targetWords" ? "targetWords.json" : "dictionary.json"
|
|
|
|
try {
|
|
return normalizeWords(await fetchJsonFile(fileName))
|
|
} catch (error) {
|
|
const embeddedWords = getEmbeddedWordList(listName)
|
|
if (embeddedWords.length > 0) {
|
|
console.info(`${fileName} unavailable; using bundled word data.`, error)
|
|
return embeddedWords
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function getEmbeddedWordList(listName) {
|
|
return normalizeWords(window.FANCY_WORDLE_WORD_DATA?.[listName])
|
|
}
|
|
|
|
function useFallbackWordLists() {
|
|
targetWords = [...FALLBACK_TARGET_WORDS]
|
|
dictionary = [...new Set([...FALLBACK_TARGET_WORDS, ...FALLBACK_EXTRA_DICTIONARY])]
|
|
dictionarySet = new Set(dictionary)
|
|
wordListMode = "fallback"
|
|
}
|
|
|
|
async function fetchJsonFile(fileName) {
|
|
const response = await fetch(fileName, { cache: "no-store" })
|
|
if (!response.ok) {
|
|
throw new Error(`${fileName} returned ${response.status}`)
|
|
}
|
|
return response.json()
|
|
}
|
|
|
|
function normalizeWords(words) {
|
|
if (!Array.isArray(words)) return []
|
|
return words
|
|
.map(word => String(word).trim().toLowerCase())
|
|
.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()
|
|
const round = remoteRound || startLocalHourlyRound(selectedWord)
|
|
|
|
applyHourlyRound(round)
|
|
}
|
|
|
|
async function startRemoteHourlyRound() {
|
|
const client = getSupabaseClient()
|
|
if (!client || !authSession) return null
|
|
|
|
try {
|
|
const { data, error } = await client.rpc("start_hourly_round")
|
|
if (error) throw error
|
|
|
|
const row = Array.isArray(data) ? data[0] : data
|
|
if (!row) throw new Error("Supabase did not return a round")
|
|
|
|
return normalizeRemoteRound(row)
|
|
} catch (error) {
|
|
console.warn("Supabase hourly lock unavailable; using local lock.", error)
|
|
showAlert("Using local hourly lock", 3000)
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getSupabaseClient() {
|
|
const config = window.FANCY_WORDLE_SUPABASE || {}
|
|
if (!config.url || !config.anonKey || !window.supabase) return null
|
|
|
|
if (!supabaseClient) {
|
|
supabaseClient = window.supabase.createClient(config.url, config.anonKey)
|
|
}
|
|
|
|
return supabaseClient
|
|
}
|
|
|
|
function normalizeRemoteRound(row) {
|
|
return {
|
|
id: row.round_id,
|
|
backend: "supabase",
|
|
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,
|
|
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
|
|
|
|
const now = Date.now()
|
|
const hourStart = getCurrentHourStart(now)
|
|
const round = {
|
|
id: `local-${now}`,
|
|
backend: "local",
|
|
word: selectedWord,
|
|
hourStart,
|
|
startedAt: now,
|
|
nextPlayableAt: hourStart + PLAY_INTERVAL_MS,
|
|
completedAt: null,
|
|
won: null,
|
|
guessCount: null,
|
|
isExisting: false
|
|
}
|
|
|
|
saveLocalHourlyRound(round)
|
|
return round
|
|
}
|
|
|
|
function getLocalHourlyRound() {
|
|
try {
|
|
const round = JSON.parse(localStorage.getItem(LOCAL_ROUND_KEY))
|
|
if (!round?.word || !round?.nextPlayableAt) return null
|
|
|
|
return {
|
|
...round,
|
|
backend: "local",
|
|
hourStart: Number(round.hourStart) || getCurrentHourStart(Number(round.startedAt) || Date.now()),
|
|
startedAt: Number(round.startedAt),
|
|
nextPlayableAt: Number(round.nextPlayableAt),
|
|
completedAt: round.completedAt ? Number(round.completedAt) : null,
|
|
isExisting: true
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function saveLocalHourlyRound(round) {
|
|
localStorage.setItem(LOCAL_ROUND_KEY, JSON.stringify(round))
|
|
}
|
|
|
|
function getCurrentHourlyWord() {
|
|
const hourStart = getCurrentHourStart(Date.now())
|
|
const hourIndex = Math.floor(hourStart / PLAY_INTERVAL_MS)
|
|
return targetWords[hourIndex % targetWords.length]
|
|
}
|
|
|
|
function getCurrentHourStart(timestamp) {
|
|
return Math.floor(timestamp / PLAY_INTERVAL_MS) * PLAY_INTERVAL_MS
|
|
}
|
|
|
|
function applyHourlyRound(round) {
|
|
hourlyRound = round
|
|
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 || ""
|
|
}
|
|
lockUntilNextWord()
|
|
return
|
|
}
|
|
|
|
roundStatus.textContent = round.isExisting ? "Hourly word resumed" : "Hourly word ready"
|
|
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 = ""
|
|
|
|
for (let index = 0; index < WORD_LENGTH * MAX_GUESSES; index += 1) {
|
|
const tile = document.createElement("div")
|
|
tile.className = "tile"
|
|
tile.setAttribute("role", "img")
|
|
tile.setAttribute("aria-label", "Empty letter")
|
|
guessGrid.append(tile)
|
|
}
|
|
}
|
|
|
|
function createKeyboard() {
|
|
keyboard.innerHTML = ""
|
|
|
|
KEY_ROWS.forEach((row, rowIndex) => {
|
|
if (rowIndex === 1) keyboard.append(createSpacer())
|
|
|
|
if (rowIndex === 2) {
|
|
keyboard.append(createActionKey("Enter", "Enter", "data-enter"))
|
|
}
|
|
|
|
row.split("").forEach(letter => {
|
|
const key = document.createElement("button")
|
|
key.className = "key"
|
|
key.type = "button"
|
|
key.dataset.key = letter
|
|
key.textContent = letter
|
|
key.setAttribute("aria-label", letter)
|
|
keyboard.append(key)
|
|
})
|
|
|
|
if (rowIndex === 2) {
|
|
keyboard.append(createActionKey("⌫", "Delete letter", "data-delete"))
|
|
}
|
|
})
|
|
}
|
|
|
|
function createSpacer() {
|
|
const spacer = document.createElement("span")
|
|
spacer.className = "key space"
|
|
spacer.setAttribute("aria-hidden", "true")
|
|
return spacer
|
|
}
|
|
|
|
function createActionKey(label, ariaLabel, attribute) {
|
|
const key = document.createElement("button")
|
|
key.className = "key large"
|
|
key.type = "button"
|
|
key.textContent = label
|
|
key.setAttribute(attribute, "")
|
|
key.setAttribute("aria-label", ariaLabel)
|
|
return key
|
|
}
|
|
|
|
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 => {
|
|
event.preventDefault()
|
|
window.location.reload()
|
|
})
|
|
}
|
|
|
|
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)
|
|
document.addEventListener("keydown", handleKeyPress)
|
|
}
|
|
|
|
function stopInteraction() {
|
|
document.removeEventListener("click", handleMouseClick)
|
|
document.removeEventListener("keydown", handleKeyPress)
|
|
}
|
|
|
|
function handleMouseClick(event) {
|
|
const keyButton = event.target.closest("[data-key]")
|
|
if (keyButton) {
|
|
pressKey(keyButton.dataset.key)
|
|
return
|
|
}
|
|
|
|
if (event.target.closest("[data-enter]")) {
|
|
submitGuess()
|
|
return
|
|
}
|
|
|
|
if (event.target.closest("[data-delete]")) {
|
|
deleteKey()
|
|
}
|
|
}
|
|
|
|
function handleKeyPress(event) {
|
|
if (statsModal.open || authModal?.open) return
|
|
|
|
if (event.key === "Enter") {
|
|
submitGuess()
|
|
return
|
|
}
|
|
|
|
if (event.key === "Backspace" || event.key === "Delete") {
|
|
deleteKey()
|
|
return
|
|
}
|
|
|
|
if (/^[a-z]$/i.test(event.key)) {
|
|
pressKey(event.key)
|
|
}
|
|
}
|
|
|
|
function pressKey(key) {
|
|
if (gameFinished || isAnimating) return
|
|
|
|
if (currentTileIndex >= WORD_LENGTH) return
|
|
|
|
const nextTile = getCurrentRowTiles()[currentTileIndex]
|
|
if (!nextTile) return
|
|
|
|
const letter = key.toLowerCase()
|
|
nextTile.dataset.letter = letter
|
|
nextTile.textContent = letter
|
|
nextTile.dataset.state = "active"
|
|
nextTile.setAttribute("aria-label", letter)
|
|
currentTileIndex += 1
|
|
}
|
|
|
|
function deleteKey() {
|
|
if (gameFinished || isAnimating) return
|
|
|
|
if (currentTileIndex === 0) return
|
|
|
|
currentTileIndex -= 1
|
|
const lastTile = getCurrentRowTiles()[currentTileIndex]
|
|
if (!lastTile) return
|
|
|
|
lastTile.textContent = ""
|
|
lastTile.removeAttribute("data-state")
|
|
lastTile.removeAttribute("data-letter")
|
|
lastTile.setAttribute("aria-label", "Empty letter")
|
|
}
|
|
|
|
async function submitGuess() {
|
|
if (gameFinished || isAnimating) return
|
|
|
|
const activeTiles = getCurrentRowTiles().filter(tile => tile.dataset.letter)
|
|
if (activeTiles.length !== WORD_LENGTH) {
|
|
showAlert("Not enough letters")
|
|
shakeTiles(activeTiles)
|
|
return
|
|
}
|
|
|
|
const guess = activeTiles.map(tile => tile.dataset.letter).join("")
|
|
if (!isValidGuess(guess)) {
|
|
showAlert("Not in word list")
|
|
shakeTiles(activeTiles)
|
|
return
|
|
}
|
|
|
|
stopInteraction()
|
|
isAnimating = true
|
|
roundStatus.textContent = "Checking guess…"
|
|
|
|
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()
|
|
|
|
activeTiles.forEach((tile, index, tiles) => {
|
|
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)
|
|
}
|
|
|
|
function scoreGuess(guess, answer) {
|
|
const states = Array(WORD_LENGTH).fill("wrong")
|
|
const remaining = {}
|
|
|
|
for (let index = 0; index < WORD_LENGTH; index += 1) {
|
|
if (guess[index] === answer[index]) {
|
|
states[index] = "correct"
|
|
} else {
|
|
remaining[answer[index]] = (remaining[answer[index]] || 0) + 1
|
|
}
|
|
}
|
|
|
|
for (let index = 0; index < WORD_LENGTH; index += 1) {
|
|
const letter = guess[index]
|
|
if (states[index] === "correct") continue
|
|
if (remaining[letter] > 0) {
|
|
states[index] = "wrong-position"
|
|
remaining[letter] -= 1
|
|
}
|
|
}
|
|
|
|
return states
|
|
}
|
|
|
|
function flipTile(tile, index, tiles, guess, state, roundResult) {
|
|
const letter = tile.dataset.letter
|
|
const key = keyboard.querySelector(`[data-key="${letter}"i]`)
|
|
|
|
setTimeout(() => {
|
|
tile.classList.add("flip")
|
|
|
|
setTimeout(() => {
|
|
tile.classList.remove("flip")
|
|
tile.textContent = letter
|
|
tile.dataset.state = state
|
|
tile.setAttribute("aria-label", `${letter}, ${readableState(state)}`)
|
|
updateKeyState(key, state)
|
|
|
|
if (index === tiles.length - 1) {
|
|
setTimeout(() => {
|
|
currentTileIndex = 0
|
|
isAnimating = false
|
|
checkWinLose(guess, tiles, roundResult)
|
|
}, FLIP_ANIMATION_DURATION / 2)
|
|
}
|
|
}, FLIP_ANIMATION_DURATION / 2)
|
|
}, (index * FLIP_ANIMATION_DURATION) / 2)
|
|
}
|
|
|
|
function updateKeyState(key, state) {
|
|
if (!key) return
|
|
|
|
const rank = { wrong: 1, "wrong-position": 2, correct: 3 }
|
|
const currentState = ["wrong", "wrong-position", "correct"].find(name => key.classList.contains(name))
|
|
|
|
if (!currentState || rank[state] > rank[currentState]) {
|
|
key.classList.remove("wrong", "wrong-position", "correct")
|
|
key.classList.add(state)
|
|
}
|
|
}
|
|
|
|
function readableState(state) {
|
|
if (state === "correct") return "correct"
|
|
if (state === "wrong-position") return "in the word, wrong spot"
|
|
return "not in the word"
|
|
}
|
|
|
|
function getActiveTiles() {
|
|
return guessGrid.querySelectorAll('[data-state="active"]')
|
|
}
|
|
|
|
function getCurrentRowTiles() {
|
|
const tiles = [...guessGrid.children]
|
|
const rowStart = currentGuessIndex * WORD_LENGTH
|
|
return tiles.slice(rowStart, rowStart + WORD_LENGTH)
|
|
}
|
|
|
|
function showAlert(message, duration = 1400) {
|
|
const alert = document.createElement("div")
|
|
alert.textContent = message
|
|
alert.className = "alert"
|
|
alertContainer.prepend(alert)
|
|
|
|
if (duration == null) return
|
|
|
|
setTimeout(() => dismissAlert(alert), duration)
|
|
}
|
|
|
|
function dismissAlert(alert) {
|
|
alert.classList.add("hide")
|
|
alert.addEventListener("transitionend", () => alert.remove(), { once: true })
|
|
}
|
|
|
|
function shakeTiles(tiles) {
|
|
tiles.forEach(tile => {
|
|
tile.classList.add("shake")
|
|
tile.addEventListener("animationend", () => tile.classList.remove("shake"), { once: true })
|
|
})
|
|
}
|
|
|
|
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)
|
|
playCelebrationSound()
|
|
showAlert("Congratulations, you win!", 5000)
|
|
danceTiles(tiles)
|
|
celebrateWithConfetti()
|
|
roundStatus.textContent = `Solved in ${currentGuessIndex}`
|
|
setTimeout(() => showWordDefinition(targetWord, true), 1800)
|
|
startLockCountdown(2400)
|
|
showLeaderboardAfterCompletion()
|
|
stopInteraction()
|
|
return
|
|
}
|
|
|
|
const remainingTiles = guessGrid.querySelectorAll(":not([data-letter])")
|
|
const lostRound = roundResult ? roundResult.completed && !roundResult.won : remainingTiles.length === 0
|
|
if (lostRound) {
|
|
gameFinished = true
|
|
lastResult = { won: false, guesses: MAX_GUESSES, word: targetWord }
|
|
saveGameResult(lastResult)
|
|
showAlert(targetWord.toUpperCase(), null)
|
|
roundStatus.textContent = "Round complete"
|
|
showWordDefinition(targetWord, false)
|
|
startLockCountdown(2400)
|
|
showLeaderboardAfterCompletion()
|
|
stopInteraction()
|
|
return
|
|
}
|
|
|
|
roundStatus.textContent = "Keep going"
|
|
startInteraction()
|
|
}
|
|
|
|
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()
|
|
startLockCountdown()
|
|
}
|
|
|
|
function startLockCountdown(delay = 0) {
|
|
clearLockCountdown()
|
|
|
|
const beginCountdown = () => {
|
|
updateLockCountdown()
|
|
lockCountdownTimer = setInterval(updateLockCountdown, 1000)
|
|
}
|
|
|
|
if (delay > 0) {
|
|
lockCountdownTimer = setTimeout(beginCountdown, delay)
|
|
return
|
|
}
|
|
|
|
beginCountdown()
|
|
}
|
|
|
|
function clearLockCountdown() {
|
|
if (!lockCountdownTimer) return
|
|
|
|
clearTimeout(lockCountdownTimer)
|
|
clearInterval(lockCountdownTimer)
|
|
lockCountdownTimer = null
|
|
}
|
|
|
|
function updateLockCountdown() {
|
|
if (!hourlyRound?.nextPlayableAt) return
|
|
|
|
const remaining = hourlyRound.nextPlayableAt - Date.now()
|
|
if (remaining <= 0) {
|
|
clearLockCountdown()
|
|
if (hourlyRound.backend === "local") localStorage.removeItem(LOCAL_ROUND_KEY)
|
|
roundStatus.textContent = "New word available"
|
|
triesStatus.textContent = "Ready"
|
|
showAlert("New word available. Refresh to play.", null)
|
|
return
|
|
}
|
|
|
|
roundStatus.textContent = `Next word in ${formatRemainingTime(remaining)}`
|
|
triesStatus.textContent = "Locked"
|
|
updateLeaderboardCountdown()
|
|
}
|
|
|
|
function formatRemainingTime(milliseconds) {
|
|
const totalSeconds = Math.max(1, Math.ceil(milliseconds / 1000))
|
|
const minutes = Math.floor(totalSeconds / 60)
|
|
const seconds = totalSeconds % 60
|
|
|
|
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`
|
|
return `${seconds}s`
|
|
}
|
|
|
|
function playCelebrationSound() {
|
|
const audio = new Audio("Celebration.mp3")
|
|
audio.volume = 0.45
|
|
audio.play().catch(error => {
|
|
console.info("Celebration sound was blocked or unavailable:", error)
|
|
})
|
|
}
|
|
|
|
async function showWordDefinition(word, isWin = true) {
|
|
try {
|
|
const response = await fetch(`https://api.dictionaryapi.dev/api/v2/entries/en/${word}`)
|
|
if (!response.ok) throw new Error(`API request failed: ${response.status}`)
|
|
|
|
const data = await response.json()
|
|
const firstMeaning = data[0]?.meanings?.[0]
|
|
const definition = firstMeaning?.definitions?.[0]?.definition
|
|
const partOfSpeech = firstMeaning?.partOfSpeech
|
|
const example = firstMeaning?.definitions?.[0]?.example
|
|
|
|
if (!definition) throw new Error("No definition found")
|
|
|
|
showDefinitionAlert({
|
|
title: `${word.toUpperCase()} ${partOfSpeech ? `(${partOfSpeech})` : ""}`,
|
|
body: definition,
|
|
example,
|
|
isWin
|
|
})
|
|
} catch (error) {
|
|
console.info("Definition lookup unavailable:", error)
|
|
showDefinitionAlert({
|
|
title: word.toUpperCase(),
|
|
body: "Definition not available at the moment.",
|
|
isWin
|
|
})
|
|
}
|
|
}
|
|
|
|
function showDefinitionAlert({ title, body, example, isWin }) {
|
|
const alert = document.createElement("button")
|
|
alert.type = "button"
|
|
alert.className = `alert definition-alert ${isWin ? "win-definition" : "lose-definition"}`
|
|
alert.innerHTML = `
|
|
<strong>${escapeHtml(title)}</strong>
|
|
<span>${escapeHtml(body)}</span>
|
|
${example ? `<span><br><br>Example: “${escapeHtml(example)}”</span>` : ""}
|
|
`
|
|
|
|
alertContainer.prepend(alert)
|
|
alert.addEventListener("click", () => dismissAlert(alert))
|
|
setTimeout(() => dismissAlert(alert), 10000)
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'")
|
|
}
|
|
|
|
function celebrateWithConfetti() {
|
|
if (typeof confetti === "undefined") return
|
|
|
|
const colors = ["#c87949", "#e8c66f", "#7e9f70", "#f7efe6", "#332820"]
|
|
confetti({ particleCount: 90, spread: 70, origin: { y: 0.58 }, colors })
|
|
|
|
setTimeout(() => {
|
|
confetti({ particleCount: 46, angle: 60, spread: 55, origin: { x: 0 }, colors })
|
|
confetti({ particleCount: 46, angle: 120, spread: 55, origin: { x: 1 }, colors })
|
|
}, 220)
|
|
}
|
|
|
|
function danceTiles(tiles) {
|
|
tiles.forEach((tile, index) => {
|
|
setTimeout(() => {
|
|
tile.classList.add("dance")
|
|
tile.addEventListener("animationend", () => tile.classList.remove("dance"), { once: true })
|
|
}, (index * DANCE_ANIMATION_DURATION) / WORD_LENGTH)
|
|
})
|
|
}
|
|
|
|
function restoreTheme() {
|
|
const selectedTheme = localStorage.getItem("selected-theme")
|
|
if (selectedTheme === "dark") {
|
|
document.body.classList.add("dark-theme")
|
|
themeButton.setAttribute("aria-label", "Switch to light mode")
|
|
}
|
|
}
|
|
|
|
function toggleTheme() {
|
|
document.body.classList.toggle("dark-theme")
|
|
const theme = document.body.classList.contains("dark-theme") ? "dark" : "light"
|
|
localStorage.setItem("selected-theme", theme)
|
|
themeButton.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode")
|
|
}
|
|
|
|
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,
|
|
wins: 0,
|
|
currentStreak: 0,
|
|
maxStreak: 0,
|
|
distribution: [0, 0, 0, 0, 0, 0]
|
|
}
|
|
|
|
try {
|
|
return { ...fallback, ...JSON.parse(localStorage.getItem(STATS_KEY)) }
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
function saveStats(stats) {
|
|
localStorage.setItem(STATS_KEY, JSON.stringify(stats))
|
|
}
|
|
|
|
function saveGameResult(result) {
|
|
if (hourlyRound?.backend === "supabase") return
|
|
|
|
const stats = getStats()
|
|
stats.played += 1
|
|
|
|
if (result.won) {
|
|
stats.wins += 1
|
|
stats.currentStreak += 1
|
|
stats.maxStreak = Math.max(stats.maxStreak, stats.currentStreak)
|
|
stats.distribution[result.guesses - 1] += 1
|
|
} else {
|
|
stats.currentStreak = 0
|
|
}
|
|
|
|
saveStats(stats)
|
|
if (!hourlyRound || hourlyRound.completedAt) return
|
|
|
|
hourlyRound = {
|
|
...hourlyRound,
|
|
completedAt: Date.now(),
|
|
won: result.won,
|
|
guessCount: result.guesses
|
|
}
|
|
|
|
saveLocalHourlyRound(hourlyRound)
|
|
}
|
|
|
|
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],
|
|
["Win %", winRate],
|
|
["Streak", stats.currentStreak],
|
|
["Best", stats.maxStreak]
|
|
]
|
|
|
|
statsGrid.innerHTML = statItems
|
|
.map(([label, value]) => `<div class="stat-item"><strong>${value}</strong><span>${label}</span></div>`)
|
|
.join("")
|
|
|
|
const maxGuessCount = Math.max(1, ...stats.distribution)
|
|
guessBars.innerHTML = stats.distribution
|
|
.map((count, index) => {
|
|
const width = Math.max(8, Math.round((count / maxGuessCount) * 100))
|
|
return `
|
|
<div class="guess-bar">
|
|
<span class="guess-bar-label">${index + 1}</span>
|
|
<span class="guess-bar-fill" style="width:${width}%">${count}</span>
|
|
</div>
|
|
`
|
|
})
|
|
.join("")
|
|
|
|
renderStatsNote(summary)
|
|
await renderHistory()
|
|
}
|
|
|
|
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)
|
|
await renderStats()
|
|
showAlert("Stats reset")
|
|
}
|
|
|
|
async function shareResult() {
|
|
const text = await buildShareText()
|
|
|
|
try {
|
|
if (navigator.share) {
|
|
await navigator.share({ text })
|
|
} else if (navigator.clipboard) {
|
|
await navigator.clipboard.writeText(text)
|
|
showAlert("Result copied")
|
|
} else {
|
|
showAlert(text, 5000)
|
|
}
|
|
} catch (error) {
|
|
console.info("Share cancelled or unavailable:", error)
|
|
}
|
|
}
|
|
|
|
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}${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() {
|
|
if (!Number.isFinite(hourlyRound?.hourStart)) return "Hourly"
|
|
|
|
return new Date(hourlyRound.hourStart).toISOString().slice(0, 13).replace("T", " ") + ":00 UTC"
|
|
}
|
|
|
|
function getResultGrid() {
|
|
const submittedTiles = [...guessGrid.querySelectorAll("[data-letter]")]
|
|
.filter(tile => tile.dataset.state && tile.dataset.state !== "active")
|
|
|
|
if (submittedTiles.length === 0) return "No guesses yet."
|
|
|
|
return chunk(submittedTiles, WORD_LENGTH)
|
|
.map(row => row.map(tile => {
|
|
if (tile.dataset.state === "correct") return "🟩"
|
|
if (tile.dataset.state === "wrong-position") return "🟨"
|
|
return "⬛"
|
|
}).join(""))
|
|
.join("\n")
|
|
}
|
|
|
|
function chunk(items, size) {
|
|
const rows = []
|
|
for (let index = 0; index < items.length; index += size) {
|
|
rows.push(items.slice(index, index + size))
|
|
}
|
|
return rows
|
|
}
|