Update Version (Beta): login + leaderboard

This commit is contained in:
Zakaria 2026-05-12 19:00:46 -04:00
parent 3175a4dbf1
commit 66f8207985
10 changed files with 17132 additions and 232 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -124,6 +124,7 @@ a {
} }
.icon-button, .icon-button,
.auth-button,
.button { .button {
min-width: 44px; min-width: 44px;
min-height: 44px; min-height: 44px;
@ -143,6 +144,7 @@ a {
} }
.icon-button:hover, .icon-button:hover,
.auth-button:hover,
.button:hover { .button:hover {
transform: translateY(-1px); transform: translateY(-1px);
border-color: color-mix(in oklch, var(--accent), var(--border) 45%); border-color: color-mix(in oklch, var(--accent), var(--border) 45%);
@ -452,6 +454,145 @@ h2 {
margin-bottom: 22px; margin-bottom: 22px;
} }
.stats-note,
.leaderboard-countdown {
margin: -8px 0 18px;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.5;
}
.history-panel {
display: grid;
gap: 10px;
margin-bottom: 22px;
}
.history-panel h3 {
margin: 0;
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 500;
}
.history-list {
display: grid;
gap: 8px;
}
.history-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--bg);
}
.history-word {
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.history-meta {
color: var(--muted);
font-size: 0.84rem;
}
.leaderboard-card {
display: grid;
gap: 18px;
}
.leaderboard-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.leaderboard-tab {
min-height: 38px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
color: var(--fg);
cursor: pointer;
font-size: 0.82rem;
font-weight: 900;
}
.leaderboard-tab.active {
border-color: var(--accent);
background: var(--accent);
color: white;
}
.leaderboard-list {
display: grid;
gap: 10px;
}
.leaderboard-row,
.leaderboard-empty {
border: 1px solid var(--border);
border-radius: 16px;
background: var(--bg);
}
.leaderboard-row {
display: grid;
grid-template-columns: 2.4rem 1fr auto;
align-items: center;
gap: 12px;
min-height: 58px;
padding: 10px 12px;
}
.leaderboard-row.you {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--accent), transparent 60%);
}
.leaderboard-rank {
display: grid;
width: 2.1rem;
height: 2.1rem;
place-items: center;
border-radius: 999px;
background: var(--fg);
color: var(--surface);
font-family: var(--font-display);
}
.leaderboard-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 800;
}
.leaderboard-streak {
display: block;
color: var(--muted);
font-size: 0.76rem;
font-weight: 700;
}
.leaderboard-score {
color: var(--muted);
font-size: 0.86rem;
font-variant-numeric: tabular-nums;
}
.leaderboard-empty {
padding: 18px;
color: var(--muted);
line-height: 1.55;
}
.guess-bar { .guess-bar {
gap: 10px; gap: 10px;
} }
@ -480,6 +621,16 @@ h2 {
font-weight: 700; font-weight: 700;
} }
.auth-button {
padding: 0 14px;
font-size: 0.88rem;
font-weight: 800;
}
.full-width {
width: 100%;
}
.button.primary { .button.primary {
background: var(--accent); background: var(--accent);
color: white; color: white;
@ -489,6 +640,128 @@ h2 {
background: var(--key); background: var(--key);
} }
.auth-card {
display: grid;
gap: 16px;
}
.auth-status {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.auth-form {
display: grid;
gap: 10px;
}
.auth-form[hidden],
.auth-create-fields[hidden],
.button[hidden] {
display: none;
}
.auth-create-fields {
display: grid;
gap: 10px;
}
.auth-reset-fields {
display: grid;
gap: 10px;
margin-top: 4px;
}
.auth-reset-fields[hidden] {
display: none;
}
.account-summary {
display: grid;
gap: 4px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--bg);
}
.account-summary[hidden] {
display: none;
}
.account-summary strong {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 500;
}
.account-summary span {
color: var(--muted);
font-size: 0.85rem;
}
.auth-form label {
color: var(--muted);
font-size: 0.82rem;
font-weight: 800;
}
.optional-label {
font-weight: 600;
opacity: 0.72;
}
.auth-form input {
min-height: 46px;
width: 100%;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--bg);
color: var(--fg);
font: inherit;
padding: 0 14px;
}
.auth-button-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 4px;
}
.auth-button-row .button:only-child,
.auth-button-row .button[hidden] + .button,
.auth-button-row .button:first-child:last-child {
grid-column: 1 / -1;
}
.auth-button-row:not(:has(#auth-sign-up:not([hidden]))) #auth-sign-in {
grid-column: 1 / -1;
}
.auth-form input:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent), transparent 75%);
}
.auth-actions {
margin: 0;
padding: 0;
}
.link-button {
width: max-content;
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font-weight: 800;
padding: 0;
text-align: start;
}
@keyframes shake { @keyframes shake {
10%, 90% { transform: translateX(-4%); } 10%, 90% { transform: translateX(-4%); }
30%, 70% { transform: translateX(5%); } 30%, 70% { transform: translateX(5%); }

View File

@ -4,7 +4,9 @@ A Wordle-style game with one shared word for every UTC hour.
## Supabase setup ## Supabase setup
The app can enforce the shared hourly word with Supabase/Postgres. Run `supabase/schema.sql` in your Supabase SQL editor, enable anonymous sign-ins in Supabase Auth, then fill in `supabase-config.js`: The app can enforce the shared hourly word with Supabase/Postgres. Run `supabase/schema.sql` in your Supabase SQL editor, then run `supabase/seed-word-data.sql` to load all answers and accepted guesses.
Enable the Email provider in Supabase Auth. The UI signs users in with email and password; if the account is missing, it reveals account creation with username, email, and password. Then fill in `supabase-config.js`:
```js ```js
window.FANCY_WORDLE_SUPABASE = { window.FANCY_WORDLE_SUPABASE = {
@ -13,10 +15,18 @@ window.FANCY_WORDLE_SUPABASE = {
} }
``` ```
The database chooses the word from `public.wordle_words` based on the current UTC hour, so everyone who plays during the same hour gets the same answer. The database chooses the word from `public.wordle_words` based on the current UTC hour, so everyone who plays during the same hour gets the same answer. Signed-in users submit guesses through Supabase, which validates guesses, scores tiles, stores completed rounds, and calculates synced stats.
`supabase/schema.sql` seeds the first 120 target words. Add more rows to `public.wordle_words` if you want a longer no-repeat cycle. The hourly leaderboard uses completed authenticated rounds for the current UTC hour. It ranks wins first, then fewer guesses, then earliest completion time.
Leaderboard tabs include this hour, today, and all time. Signed-in users are included even if their row falls outside the top 25. Stats also show recent personal history and this hour's average score summary.
`supabase/seed-word-data.sql` is generated from `targetWords.json` and `dictionary.json`:
```sh
node scripts/generate-supabase-word-seed.mjs
```
If Supabase is not configured, the app falls back to a browser-only hourly lock using the same deterministic hourly word calculation from `targetWords.json`. If Supabase is not configured, the app falls back to a browser-only hourly lock using the same deterministic hourly word calculation from `targetWords.json`.
Anonymous auth enforces the limit per browser session. For stronger per-person enforcement, replace anonymous sign-in with email or OAuth sign-in. Guest play uses local browser storage. Sign in with email and password to persist scores across devices.

View File

@ -10,7 +10,7 @@
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script> <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<script src="word-data.js?v=5" defer></script> <script src="word-data.js?v=5" defer></script>
<script src="supabase-config.js?v=1" defer></script> <script src="supabase-config.js?v=1" defer></script>
<script src="script.js?v=6" defer></script> <script src="script.js?v=7" defer></script>
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
@ -30,6 +30,10 @@
<button class="icon-button" type="button" id="Stats-button" aria-label="Open stats and share" title="Stats and share"> <button class="icon-button" type="button" id="Stats-button" aria-label="Open stats and share" title="Stats and share">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</button> </button>
<button class="icon-button" type="button" id="leaderboard-button" aria-label="Open hourly leaderboard" title="Hourly leaderboard">
<span aria-hidden="true"></span>
</button>
<button class="auth-button" type="button" id="auth-button" aria-label="Sign in to sync stats">Sign in</button>
<a class="icon-button" href="https://www.youtube.com/watch?v=UkOKCWDJ4iA" target="_blank" rel="noopener noreferrer" aria-label="Open the original love note" title="Original love note"> <a class="icon-button" href="https://www.youtube.com/watch?v=UkOKCWDJ4iA" target="_blank" rel="noopener noreferrer" aria-label="Open the original love note" title="Original love note">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </a>
@ -66,12 +70,74 @@
<button class="icon-button close-button" type="submit" aria-label="Close stats">×</button> <button class="icon-button close-button" type="submit" aria-label="Close stats">×</button>
</div> </div>
<div class="stats-grid" id="stats-grid"></div> <div class="stats-grid" id="stats-grid"></div>
<div class="stats-note" id="stats-note"></div>
<div class="guess-bars" id="guess-bars" aria-label="Guess distribution"></div> <div class="guess-bars" id="guess-bars" aria-label="Guess distribution"></div>
<div class="history-panel">
<h3>Recent history</h3>
<div class="history-list" id="history-list"></div>
</div>
<menu class="modal-actions"> <menu class="modal-actions">
<button type="button" class="button secondary" id="reset-stats">Reset stats</button> <button type="button" class="button secondary" id="reset-stats">Reset stats</button>
<button type="button" class="button primary" id="share-results">Share result</button> <button type="button" class="button primary" id="share-results">Share result</button>
</menu> </menu>
</form> </form>
</dialog> </dialog>
<dialog class="stats-modal" id="leaderboard-modal" aria-labelledby="leaderboard-title">
<form method="dialog" class="stats-card leaderboard-card">
<div class="modal-head">
<div>
<p class="eyebrow">This hour</p>
<h2 id="leaderboard-title">Leaderboard</h2>
</div>
<button class="icon-button close-button" type="submit" aria-label="Close leaderboard">×</button>
</div>
<div class="leaderboard-tabs" role="tablist" aria-label="Leaderboard scope">
<button type="button" class="leaderboard-tab active" data-leaderboard-scope="hour">This Hour</button>
<button type="button" class="leaderboard-tab" data-leaderboard-scope="today">Today</button>
<button type="button" class="leaderboard-tab" data-leaderboard-scope="all">All Time</button>
</div>
<p class="leaderboard-countdown" id="leaderboard-countdown">Next word unlocks soon.</p>
<div class="leaderboard-list" id="leaderboard-list" aria-live="polite"></div>
</form>
</dialog>
<dialog class="stats-modal auth-modal" id="auth-modal" aria-labelledby="auth-title">
<form method="dialog" class="stats-card auth-card">
<div class="modal-head">
<div>
<p class="eyebrow">Account</p>
<h2 id="auth-title">Sign in</h2>
</div>
<button class="icon-button close-button" type="submit" aria-label="Close account">×</button>
</div>
<p class="auth-status" id="auth-status">Sign in with your email and password. If we cannot find your account, you can create one here.</p>
<div class="account-summary" id="account-summary" hidden></div>
<div id="auth-form" class="auth-form">
<label for="auth-email">Email address</label>
<input type="email" id="auth-email" autocomplete="email" placeholder="you@example.com">
<label for="auth-password">Password</label>
<input type="password" id="auth-password" autocomplete="current-password" placeholder="At least 6 characters" minlength="6">
<div class="auth-create-fields" id="auth-create-fields" hidden>
<label for="auth-username">Username</label>
<input type="text" id="auth-username" autocomplete="username" placeholder="wordsmith" maxlength="24">
</div>
<div class="auth-button-row">
<button type="button" class="button primary" id="auth-sign-in">Sign in</button>
<button type="button" class="button secondary" id="auth-sign-up" hidden>Create account</button>
</div>
<button type="button" class="link-button" id="auth-forgot-password">Forgot password?</button>
<div class="auth-reset-fields" id="auth-reset-fields" hidden>
<label for="auth-new-password">New password</label>
<input type="password" id="auth-new-password" autocomplete="new-password" placeholder="New password" minlength="6">
<button type="button" class="button secondary full-width" id="auth-update-password">Update password</button>
</div>
</div>
<menu class="modal-actions auth-actions">
<button type="button" class="button secondary" id="auth-guest">Continue as guest</button>
<button type="button" class="button secondary" id="auth-sign-out" hidden>Sign out</button>
</menu>
</form>
</dialog>
</body> </body>
</html> </html>

View File

@ -10,7 +10,7 @@
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script> <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<script src="word-data.js?v=5" defer></script> <script src="word-data.js?v=5" defer></script>
<script src="supabase-config.js?v=1" defer></script> <script src="supabase-config.js?v=1" defer></script>
<script src="script.js?v=6" defer></script> <script src="script.js?v=7" defer></script>
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
@ -30,6 +30,10 @@
<button class="icon-button" type="button" id="Stats-button" aria-label="Open stats and share" title="Stats and share"> <button class="icon-button" type="button" id="Stats-button" aria-label="Open stats and share" title="Stats and share">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</button> </button>
<button class="icon-button" type="button" id="leaderboard-button" aria-label="Open hourly leaderboard" title="Hourly leaderboard">
<span aria-hidden="true"></span>
</button>
<button class="auth-button" type="button" id="auth-button" aria-label="Sign in to sync stats">Sign in</button>
<a class="icon-button" href="https://www.youtube.com/watch?v=UkOKCWDJ4iA" target="_blank" rel="noopener noreferrer" aria-label="Open the original love note" title="Original love note"> <a class="icon-button" href="https://www.youtube.com/watch?v=UkOKCWDJ4iA" target="_blank" rel="noopener noreferrer" aria-label="Open the original love note" title="Original love note">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </a>
@ -66,12 +70,74 @@
<button class="icon-button close-button" type="submit" aria-label="Close stats">×</button> <button class="icon-button close-button" type="submit" aria-label="Close stats">×</button>
</div> </div>
<div class="stats-grid" id="stats-grid"></div> <div class="stats-grid" id="stats-grid"></div>
<div class="stats-note" id="stats-note"></div>
<div class="guess-bars" id="guess-bars" aria-label="Guess distribution"></div> <div class="guess-bars" id="guess-bars" aria-label="Guess distribution"></div>
<div class="history-panel">
<h3>Recent history</h3>
<div class="history-list" id="history-list"></div>
</div>
<menu class="modal-actions"> <menu class="modal-actions">
<button type="button" class="button secondary" id="reset-stats">Reset stats</button> <button type="button" class="button secondary" id="reset-stats">Reset stats</button>
<button type="button" class="button primary" id="share-results">Share result</button> <button type="button" class="button primary" id="share-results">Share result</button>
</menu> </menu>
</form> </form>
</dialog> </dialog>
<dialog class="stats-modal" id="leaderboard-modal" aria-labelledby="leaderboard-title">
<form method="dialog" class="stats-card leaderboard-card">
<div class="modal-head">
<div>
<p class="eyebrow">This hour</p>
<h2 id="leaderboard-title">Leaderboard</h2>
</div>
<button class="icon-button close-button" type="submit" aria-label="Close leaderboard">×</button>
</div>
<div class="leaderboard-tabs" role="tablist" aria-label="Leaderboard scope">
<button type="button" class="leaderboard-tab active" data-leaderboard-scope="hour">This Hour</button>
<button type="button" class="leaderboard-tab" data-leaderboard-scope="today">Today</button>
<button type="button" class="leaderboard-tab" data-leaderboard-scope="all">All Time</button>
</div>
<p class="leaderboard-countdown" id="leaderboard-countdown">Next word unlocks soon.</p>
<div class="leaderboard-list" id="leaderboard-list" aria-live="polite"></div>
</form>
</dialog>
<dialog class="stats-modal auth-modal" id="auth-modal" aria-labelledby="auth-title">
<form method="dialog" class="stats-card auth-card">
<div class="modal-head">
<div>
<p class="eyebrow">Account</p>
<h2 id="auth-title">Sign in</h2>
</div>
<button class="icon-button close-button" type="submit" aria-label="Close account">×</button>
</div>
<p class="auth-status" id="auth-status">Sign in with your email and password. If we cannot find your account, you can create one here.</p>
<div class="account-summary" id="account-summary" hidden></div>
<div id="auth-form" class="auth-form">
<label for="auth-email">Email address</label>
<input type="email" id="auth-email" autocomplete="email" placeholder="you@example.com">
<label for="auth-password">Password</label>
<input type="password" id="auth-password" autocomplete="current-password" placeholder="At least 6 characters" minlength="6">
<div class="auth-create-fields" id="auth-create-fields" hidden>
<label for="auth-username">Username</label>
<input type="text" id="auth-username" autocomplete="username" placeholder="wordsmith" maxlength="24">
</div>
<div class="auth-button-row">
<button type="button" class="button primary" id="auth-sign-in">Sign in</button>
<button type="button" class="button secondary" id="auth-sign-up" hidden>Create account</button>
</div>
<button type="button" class="link-button" id="auth-forgot-password">Forgot password?</button>
<div class="auth-reset-fields" id="auth-reset-fields" hidden>
<label for="auth-new-password">New password</label>
<input type="password" id="auth-new-password" autocomplete="new-password" placeholder="New password" minlength="6">
<button type="button" class="button secondary full-width" id="auth-update-password">Update password</button>
</div>
</div>
<menu class="modal-actions auth-actions">
<button type="button" class="button secondary" id="auth-guest">Continue as guest</button>
<button type="button" class="button secondary" id="auth-sign-out" hidden>Sign out</button>
</menu>
</form>
</dialog>
</body> </body>
</html> </html>

785
script.js
View File

@ -12,6 +12,10 @@ let isAnimating = false
let hourlyRound = null let hourlyRound = null
let lockCountdownTimer = null let lockCountdownTimer = null
let supabaseClient = null let supabaseClient = null
let authSession = null
let authProfile = null
let leaderboardScope = "hour"
let latestLeaderboardRows = []
const WORD_LENGTH = 5 const WORD_LENGTH = 5
const MAX_GUESSES = 6 const MAX_GUESSES = 6
@ -66,9 +70,33 @@ const roundStatus = document.getElementById("round-status")
const triesStatus = document.getElementById("tries-status") const triesStatus = document.getElementById("tries-status")
const themeButton = document.getElementById("theme-button") const themeButton = document.getElementById("theme-button")
const statsButton = document.getElementById("Stats-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 statsModal = document.getElementById("stats-modal")
const statsGrid = document.getElementById("stats-grid") const statsGrid = document.getElementById("stats-grid")
const statsNote = document.getElementById("stats-note")
const guessBars = document.getElementById("guess-bars") const guessBars = document.getElementById("guess-bars")
const historyList = document.getElementById("history-list")
const resetStatsButton = document.getElementById("reset-stats") const resetStatsButton = document.getElementById("reset-stats")
const shareResultsButton = document.getElementById("share-results") const shareResultsButton = document.getElementById("share-results")
@ -86,6 +114,7 @@ async function initializeGame() {
createKeyboard() createKeyboard()
restoreTheme() restoreTheme()
bindControls() bindControls()
await initializeAuth()
try { try {
await loadWordLists() await loadWordLists()
@ -167,6 +196,95 @@ function normalizeWords(words) {
.filter(word => word.length === WORD_LENGTH) .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() { async function startHourlyRound() {
const selectedWord = getCurrentHourlyWord() const selectedWord = getCurrentHourlyWord()
const remoteRound = await startRemoteHourlyRound() const remoteRound = await startRemoteHourlyRound()
@ -177,10 +295,9 @@ async function startHourlyRound() {
async function startRemoteHourlyRound() { async function startRemoteHourlyRound() {
const client = getSupabaseClient() const client = getSupabaseClient()
if (!client) return null if (!client || !authSession) return null
try { try {
await ensureSupabaseSession(client)
const { data, error } = await client.rpc("start_hourly_round") const { data, error } = await client.rpc("start_hourly_round")
if (error) throw error if (error) throw error
@ -206,29 +323,32 @@ function getSupabaseClient() {
return supabaseClient 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) { function normalizeRemoteRound(row) {
return { return {
id: row.round_id, id: row.round_id,
backend: "supabase", backend: "supabase",
word: row.word, word: row.revealed_word || "",
hourStart: Date.parse(row.hour_start), hourStart: Date.parse(row.hour_start),
startedAt: Date.parse(row.started_at), startedAt: Date.parse(row.started_at),
nextPlayableAt: Date.parse(row.next_playable_at), nextPlayableAt: Date.parse(row.next_playable_at),
completedAt: row.completed_at ? Date.parse(row.completed_at) : null, completedAt: row.completed_at ? Date.parse(row.completed_at) : null,
won: row.won, won: row.won,
guessCount: row.guess_count, 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) { function startLocalHourlyRound(selectedWord) {
const savedRound = getLocalHourlyRound() const savedRound = getLocalHourlyRound()
if (savedRound && savedRound.nextPlayableAt > Date.now()) return savedRound if (savedRound && savedRound.nextPlayableAt > Date.now()) return savedRound
@ -287,16 +407,17 @@ function getCurrentHourStart(timestamp) {
function applyHourlyRound(round) { function applyHourlyRound(round) {
hourlyRound = round hourlyRound = round
targetWord = round.word targetWord = round.word || ""
dictionarySet.add(targetWord) if (targetWord) dictionarySet.add(targetWord)
currentGuessIndex = round.completedAt ? round.guessCount || 0 : 0 restoreGuesses(round.guesses || [])
currentGuessIndex = round.completedAt ? round.guessCount || currentGuessIndex : currentGuessIndex
updateTriesStatus() updateTriesStatus()
if (round.completedAt) { if (round.completedAt) {
lastResult = { lastResult = {
won: Boolean(round.won), won: Boolean(round.won),
guesses: round.guessCount || MAX_GUESSES, guesses: round.guessCount || MAX_GUESSES,
word: round.word word: round.word || ""
} }
lockUntilNextWord() lockUntilNextWord()
return return
@ -306,6 +427,33 @@ function applyHourlyRound(round) {
startInteraction() 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() { function createBoard() {
guessGrid.innerHTML = "" guessGrid.innerHTML = ""
@ -364,6 +512,20 @@ function createActionKey(label, ariaLabel, attribute) {
function bindControls() { function bindControls() {
themeButton.addEventListener("click", toggleTheme) themeButton.addEventListener("click", toggleTheme)
statsButton.addEventListener("click", openStats) 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) resetStatsButton.addEventListener("click", resetStats)
shareResultsButton.addEventListener("click", shareResult) shareResultsButton.addEventListener("click", shareResult)
document.querySelector(".brand").addEventListener("click", event => { 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() { function startInteraction() {
stopInteraction() stopInteraction()
document.addEventListener("click", handleMouseClick) document.addEventListener("click", handleMouseClick)
@ -401,7 +733,7 @@ function handleMouseClick(event) {
} }
function handleKeyPress(event) { function handleKeyPress(event) {
if (statsModal.open) return if (statsModal.open || authModal?.open) return
if (event.key === "Enter") { if (event.key === "Enter") {
submitGuess() submitGuess()
@ -449,7 +781,7 @@ function deleteKey() {
lastTile.setAttribute("aria-label", "Empty letter") lastTile.setAttribute("aria-label", "Empty letter")
} }
function submitGuess() { async function submitGuess() {
if (gameFinished || isAnimating) return if (gameFinished || isAnimating) return
const activeTiles = getCurrentRowTiles().filter(tile => tile.dataset.letter) const activeTiles = getCurrentRowTiles().filter(tile => tile.dataset.letter)
@ -469,15 +801,93 @@ function submitGuess() {
stopInteraction() stopInteraction()
isAnimating = true isAnimating = true
roundStatus.textContent = "Checking guess…" 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() updateTriesStatus()
const states = scoreGuess(guess, targetWord)
activeTiles.forEach((tile, index, tiles) => { 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) { function isValidGuess(guess) {
if (!/^[a-z]{5}$/.test(guess)) return false if (!/^[a-z]{5}$/.test(guess)) return false
return dictionarySet.has(guess) return dictionarySet.has(guess)
@ -507,7 +917,7 @@ function scoreGuess(guess, answer) {
return states return states
} }
function flipTile(tile, index, tiles, guess, state) { function flipTile(tile, index, tiles, guess, state, roundResult) {
const letter = tile.dataset.letter const letter = tile.dataset.letter
const key = keyboard.querySelector(`[data-key="${letter}"i]`) const key = keyboard.querySelector(`[data-key="${letter}"i]`)
@ -525,7 +935,7 @@ function flipTile(tile, index, tiles, guess, state) {
setTimeout(() => { setTimeout(() => {
currentTileIndex = 0 currentTileIndex = 0
isAnimating = false isAnimating = false
checkWinLose(guess, tiles) checkWinLose(guess, tiles, roundResult)
}, FLIP_ANIMATION_DURATION / 2) }, FLIP_ANIMATION_DURATION / 2)
} }
}, FLIP_ANIMATION_DURATION / 2) }, FLIP_ANIMATION_DURATION / 2)
@ -583,8 +993,21 @@ function shakeTiles(tiles) {
}) })
} }
function checkWinLose(guess, tiles) { function checkWinLose(guess, tiles, roundResult) {
if (guess === targetWord) { 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 gameFinished = true
lastResult = { won: true, guesses: currentGuessIndex, word: targetWord } lastResult = { won: true, guesses: currentGuessIndex, word: targetWord }
saveGameResult(lastResult) saveGameResult(lastResult)
@ -595,12 +1018,14 @@ function checkWinLose(guess, tiles) {
roundStatus.textContent = `Solved in ${currentGuessIndex}` roundStatus.textContent = `Solved in ${currentGuessIndex}`
setTimeout(() => showWordDefinition(targetWord, true), 1800) setTimeout(() => showWordDefinition(targetWord, true), 1800)
startLockCountdown(2400) startLockCountdown(2400)
showLeaderboardAfterCompletion()
stopInteraction() stopInteraction()
return return
} }
const remainingTiles = guessGrid.querySelectorAll(":not([data-letter])") 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 gameFinished = true
lastResult = { won: false, guesses: MAX_GUESSES, word: targetWord } lastResult = { won: false, guesses: MAX_GUESSES, word: targetWord }
saveGameResult(lastResult) saveGameResult(lastResult)
@ -608,6 +1033,7 @@ function checkWinLose(guess, tiles) {
roundStatus.textContent = "Round complete" roundStatus.textContent = "Round complete"
showWordDefinition(targetWord, false) showWordDefinition(targetWord, false)
startLockCountdown(2400) startLockCountdown(2400)
showLeaderboardAfterCompletion()
stopInteraction() stopInteraction()
return return
} }
@ -620,6 +1046,17 @@ function updateTriesStatus() {
triesStatus.textContent = `${currentGuessIndex} / ${MAX_GUESSES}` 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() { function lockUntilNextWord() {
gameFinished = true gameFinished = true
stopInteraction() stopInteraction()
@ -665,6 +1102,7 @@ function updateLockCountdown() {
roundStatus.textContent = `Next word in ${formatRemainingTime(remaining)}` roundStatus.textContent = `Next word in ${formatRemainingTime(remaining)}`
triesStatus.textContent = "Locked" triesStatus.textContent = "Locked"
updateLeaderboardCountdown()
} }
function formatRemainingTime(milliseconds) { function formatRemainingTime(milliseconds) {
@ -773,11 +1211,26 @@ function toggleTheme() {
themeButton.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode") themeButton.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode")
} }
function openStats() { async function openStats() {
renderStats() await renderStats()
statsModal.showModal() 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() { function getStats() {
const fallback = { const fallback = {
played: 0, played: 0,
@ -799,6 +1252,8 @@ function saveStats(stats) {
} }
function saveGameResult(result) { function saveGameResult(result) {
if (hourlyRound?.backend === "supabase") return
const stats = getStats() const stats = getStats()
stats.played += 1 stats.played += 1
@ -812,10 +1267,6 @@ function saveGameResult(result) {
} }
saveStats(stats) saveStats(stats)
completeHourlyRound(result)
}
function completeHourlyRound(result) {
if (!hourlyRound || hourlyRound.completedAt) return if (!hourlyRound || hourlyRound.completedAt) return
hourlyRound = { hourlyRound = {
@ -825,31 +1276,12 @@ function completeHourlyRound(result) {
guessCount: result.guesses guessCount: result.guesses
} }
if (hourlyRound.backend === "local") {
saveLocalHourlyRound(hourlyRound) saveLocalHourlyRound(hourlyRound)
return
} }
completeRemoteHourlyRound(result).catch(error => { async function renderStats() {
console.warn("Failed to complete Supabase hourly round:", error) const stats = await getDisplayStats()
}) const summary = await getHourlySummary()
}
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()
const winRate = stats.played === 0 ? 0 : Math.round((stats.wins / stats.played) * 100) const winRate = stats.played === 0 ? 0 : Math.round((stats.wins / stats.played) * 100)
const statItems = [ const statItems = [
["Played", stats.played], ["Played", stats.played],
@ -874,16 +1306,237 @@ function renderStats() {
` `
}) })
.join("") .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
} }
function resetStats() {
localStorage.removeItem(STATS_KEY) localStorage.removeItem(STATS_KEY)
renderStats() await renderStats()
showAlert("Stats reset") showAlert("Stats reset")
} }
async function shareResult() { async function shareResult() {
const text = buildShareText() const text = await buildShareText()
try { try {
if (navigator.share) { if (navigator.share) {
@ -899,13 +1552,27 @@ async function shareResult() {
} }
} }
function buildShareText() { async function buildShareText() {
const status = lastResult const status = lastResult
? `${lastResult.won ? lastResult.guesses : "X"}/${MAX_GUESSES}` ? `${lastResult.won ? lastResult.guesses : "X"}/${MAX_GUESSES}`
: `${currentGuessIndex}/${MAX_GUESSES}` : `${currentGuessIndex}/${MAX_GUESSES}`
const roundLabel = getShareRoundLabel() 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() { function getShareRoundLabel() {

View File

@ -0,0 +1,40 @@
import { readFileSync, writeFileSync } from "node:fs"
const targetWords = JSON.parse(readFileSync(new URL("../targetWords.json", import.meta.url), "utf8"))
const dictionaryWords = JSON.parse(readFileSync(new URL("../dictionary.json", import.meta.url), "utf8"))
const normalizeWords = words => [...new Set(words
.map(word => String(word).trim().toLowerCase())
.filter(word => /^[a-z]{5}$/.test(word)))]
const answers = normalizeWords(targetWords)
const dictionary = normalizeWords([...dictionaryWords, ...answers]).sort()
const quote = value => `'${value.replaceAll("'", "''")}'`
const answerRows = answers
.map((word, index) => ` (${index + 1}, ${quote(word)})`)
.join(",\n")
const dictionaryRows = dictionary
.map(word => ` (${quote(word)})`)
.join(",\n")
const sql = `-- Generated by scripts/generate-supabase-word-seed.mjs.
-- Re-run the generator after changing targetWords.json or dictionary.json.
begin;
truncate table public.wordle_words;
truncate table public.wordle_dictionary;
insert into public.wordle_words (position, word) values
${answerRows};
insert into public.wordle_dictionary (word) values
${dictionaryRows};
commit;
`
writeFileSync(new URL("../supabase/seed-word-data.sql", import.meta.url), sql)
console.log(`Wrote ${answers.length} answers and ${dictionary.length} accepted guesses.`)

View File

@ -1,4 +1,4 @@
window.FANCY_WORDLE_SUPABASE = { window.FANCY_WORDLE_SUPABASE = {
url: "", url: "https://bxrrirbaucroteyumeul.supabase.co",
anonKey: "" anonKey: "sb_publishable_moGEav5v53vMJPwnWWp4tg_Ab67sG4o"
} }

View File

@ -5,138 +5,32 @@ create table if not exists public.wordle_words (
word text not null unique check (word ~ '^[a-z]{5}$') word text not null unique check (word ~ '^[a-z]{5}$')
); );
insert into public.wordle_words (position, word) values create table if not exists public.wordle_dictionary (
(1, 'cigar'), word text primary key check (word ~ '^[a-z]{5}$')
(2, 'rebut'), );
(3, 'sissy'),
(4, 'humph'),
(5, 'awake'),
(6, 'blush'),
(7, 'focal'),
(8, 'evade'),
(9, 'naval'),
(10, 'serve'),
(11, 'heath'),
(12, 'dwarf'),
(13, 'model'),
(14, 'karma'),
(15, 'stink'),
(16, 'grade'),
(17, 'quiet'),
(18, 'bench'),
(19, 'abate'),
(20, 'feign'),
(21, 'major'),
(22, 'death'),
(23, 'fresh'),
(24, 'crust'),
(25, 'stool'),
(26, 'colon'),
(27, 'abase'),
(28, 'marry'),
(29, 'react'),
(30, 'batty'),
(31, 'pride'),
(32, 'floss'),
(33, 'helix'),
(34, 'croak'),
(35, 'staff'),
(36, 'paper'),
(37, 'unfed'),
(38, 'whelp'),
(39, 'trawl'),
(40, 'outdo'),
(41, 'adobe'),
(42, 'crazy'),
(43, 'sower'),
(44, 'repay'),
(45, 'digit'),
(46, 'crate'),
(47, 'cluck'),
(48, 'spike'),
(49, 'mimic'),
(50, 'pound'),
(51, 'maxim'),
(52, 'linen'),
(53, 'unmet'),
(54, 'flesh'),
(55, 'booby'),
(56, 'forth'),
(57, 'first'),
(58, 'stand'),
(59, 'belly'),
(60, 'ivory'),
(61, 'seedy'),
(62, 'print'),
(63, 'yearn'),
(64, 'drain'),
(65, 'bribe'),
(66, 'stout'),
(67, 'panel'),
(68, 'crass'),
(69, 'flume'),
(70, 'offal'),
(71, 'agree'),
(72, 'error'),
(73, 'swirl'),
(74, 'argue'),
(75, 'bleed'),
(76, 'delta'),
(77, 'flick'),
(78, 'totem'),
(79, 'wooer'),
(80, 'front'),
(81, 'shrub'),
(82, 'parry'),
(83, 'biome'),
(84, 'lapel'),
(85, 'start'),
(86, 'greet'),
(87, 'goner'),
(88, 'golem'),
(89, 'lusty'),
(90, 'loopy'),
(91, 'round'),
(92, 'audit'),
(93, 'lying'),
(94, 'gamma'),
(95, 'labor'),
(96, 'islet'),
(97, 'civic'),
(98, 'forge'),
(99, 'corny'),
(100, 'moult'),
(101, 'basic'),
(102, 'salad'),
(103, 'agate'),
(104, 'spicy'),
(105, 'spray'),
(106, 'essay'),
(107, 'fjord'),
(108, 'spend'),
(109, 'kebab'),
(110, 'guild'),
(111, 'aback'),
(112, 'motor'),
(113, 'alone'),
(114, 'hatch'),
(115, 'hyper'),
(116, 'thumb'),
(117, 'dowry'),
(118, 'ought'),
(119, 'belch'),
(120, 'dutch')
on conflict (position) do nothing;
alter table public.wordle_words enable row level security; create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
username text,
display_name text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.profiles
add column if not exists username text;
create unique index if not exists profiles_username_key
on public.profiles (lower(username))
where username is not null;
create table if not exists public.wordle_rounds ( create table if not exists public.wordle_rounds (
id uuid primary key default gen_random_uuid(), id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade,
word text not null check (word ~ '^[a-z]{5}$'), word text not null check (word ~ '^[a-z]{5}$'),
hour_start timestamptz, hour_start timestamptz not null,
started_at timestamptz not null default now(), started_at timestamptz not null default now(),
next_playable_at timestamptz not null default (now() + interval '1 hour'), next_playable_at timestamptz not null,
completed_at timestamptz, completed_at timestamptz,
won boolean, won boolean,
guess_count integer check (guess_count between 1 and 6), guess_count integer check (guess_count between 1 and 6),
@ -144,14 +38,31 @@ create table if not exists public.wordle_rounds (
); );
alter table public.wordle_rounds alter table public.wordle_rounds
add column if not exists hour_start timestamptz; add column if not exists hour_start timestamptz,
add column if not exists next_playable_at timestamptz;
update public.wordle_rounds update public.wordle_rounds
set hour_start = date_trunc('hour', started_at) set hour_start = date_trunc('hour', started_at)
where hour_start is null; where hour_start is null;
update public.wordle_rounds
set next_playable_at = hour_start + interval '1 hour'
where next_playable_at is null;
alter table public.wordle_rounds alter table public.wordle_rounds
alter column hour_start set not null; alter column hour_start set not null,
alter column next_playable_at set not null;
create table if not exists public.wordle_guesses (
id uuid primary key default gen_random_uuid(),
round_id uuid not null references public.wordle_rounds(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
guess text not null check (guess ~ '^[a-z]{5}$'),
row_index integer not null check (row_index between 1 and 6),
states jsonb not null,
created_at timestamptz not null default now(),
unique (round_id, row_index)
);
create index if not exists wordle_rounds_user_started_idx create index if not exists wordle_rounds_user_started_idx
on public.wordle_rounds (user_id, started_at desc); on public.wordle_rounds (user_id, started_at desc);
@ -159,7 +70,27 @@ create index if not exists wordle_rounds_user_started_idx
create unique index if not exists wordle_rounds_user_hour_idx create unique index if not exists wordle_rounds_user_hour_idx
on public.wordle_rounds (user_id, hour_start); on public.wordle_rounds (user_id, hour_start);
create index if not exists wordle_guesses_round_row_idx
on public.wordle_guesses (round_id, row_index);
alter table public.wordle_words enable row level security;
alter table public.wordle_dictionary enable row level security;
alter table public.profiles enable row level security;
alter table public.wordle_rounds enable row level security; alter table public.wordle_rounds enable row level security;
alter table public.wordle_guesses enable row level security;
drop policy if exists "Users can read their own profile" on public.profiles;
create policy "Users can read their own profile"
on public.profiles
for select
using (auth.uid() = id);
drop policy if exists "Users can update their own profile" on public.profiles;
create policy "Users can update their own profile"
on public.profiles
for update
using (auth.uid() = id)
with check (auth.uid() = id);
drop policy if exists "Users can read their own rounds" on public.wordle_rounds; drop policy if exists "Users can read their own rounds" on public.wordle_rounds;
create policy "Users can read their own rounds" create policy "Users can read their own rounds"
@ -167,12 +98,113 @@ create policy "Users can read their own rounds"
for select for select
using (auth.uid() = user_id); using (auth.uid() = user_id);
drop policy if exists "Users can read their own guesses" on public.wordle_guesses;
create policy "Users can read their own guesses"
on public.wordle_guesses
for select
using (auth.uid() = user_id);
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, username, display_name)
values (
new.id,
nullif(lower(regexp_replace(coalesce(new.raw_user_meta_data->>'username', split_part(new.email, '@', 1)), '[^a-z0-9_]', '', 'g')), ''),
coalesce(new.raw_user_meta_data->>'display_name', new.raw_user_meta_data->>'username', split_part(new.email, '@', 1))
)
on conflict (id) do nothing;
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
create or replace function public.score_guess(guess text, answer text)
returns jsonb
language plpgsql
immutable
as $$
declare
states text[] := array['wrong', 'wrong', 'wrong', 'wrong', 'wrong'];
remaining_letters text := '';
guess_letter text;
answer_letter text;
match_index integer;
index integer;
begin
for index in 1..5 loop
guess_letter := substr(guess, index, 1);
answer_letter := substr(answer, index, 1);
if guess_letter = answer_letter then
states[index] := 'correct';
else
remaining_letters := remaining_letters || answer_letter;
end if;
end loop;
for index in 1..5 loop
if states[index] = 'correct' then
continue;
end if;
guess_letter := substr(guess, index, 1);
match_index := strpos(remaining_letters, guess_letter);
if match_index > 0 then
states[index] := 'wrong-position';
remaining_letters := overlay(remaining_letters placing '' from match_index for 1);
end if;
end loop;
return to_jsonb(states);
end;
$$;
create or replace function public.get_hourly_word(hour_start timestamptz)
returns text
language plpgsql
stable
security definer
set search_path = public
as $$
declare
word_count integer;
word_offset integer;
hourly_word text;
begin
select count(*) into word_count from public.wordle_words;
if word_count = 0 then
raise exception 'No hourly words are configured';
end if;
word_offset := (floor(extract(epoch from hour_start) / 3600)::bigint % word_count)::integer;
select public.wordle_words.word
into hourly_word
from public.wordle_words
order by position
limit 1 offset word_offset;
return hourly_word;
end;
$$;
drop function if exists public.start_hourly_round(text); drop function if exists public.start_hourly_round(text);
create or replace function public.start_hourly_round() create or replace function public.start_hourly_round()
returns table ( returns table (
round_id uuid, round_id uuid,
word text,
hour_start timestamptz, hour_start timestamptz,
started_at timestamptz, started_at timestamptz,
next_playable_at timestamptz, next_playable_at timestamptz,
@ -180,7 +212,9 @@ returns table (
won boolean, won boolean,
guess_count integer, guess_count integer,
server_now timestamptz, server_now timestamptz,
is_existing boolean is_existing boolean,
revealed_word text,
guesses jsonb
) )
language plpgsql language plpgsql
security definer security definer
@ -190,27 +224,12 @@ declare
existing_round public.wordle_rounds%rowtype; existing_round public.wordle_rounds%rowtype;
new_round public.wordle_rounds%rowtype; new_round public.wordle_rounds%rowtype;
current_hour timestamptz := date_trunc('hour', now()); current_hour timestamptz := date_trunc('hour', now());
word_count integer; hourly_word text := public.get_hourly_word(date_trunc('hour', now()));
word_offset integer;
hourly_word text;
begin begin
if auth.uid() is null then if auth.uid() is null then
raise exception 'Authentication required'; raise exception 'Authentication required';
end if; end if;
select count(*) into word_count from public.wordle_words;
if word_count = 0 then
raise exception 'No hourly words are configured';
end if;
word_offset := (floor(extract(epoch from current_hour) / 3600)::bigint % word_count)::integer;
select public.wordle_words.word
into hourly_word
from public.wordle_words
order by position
limit 1 offset word_offset;
select * select *
into existing_round into existing_round
from public.wordle_rounds from public.wordle_rounds
@ -222,7 +241,6 @@ begin
if found then if found then
return query select return query select
existing_round.id, existing_round.id,
existing_round.word,
existing_round.hour_start, existing_round.hour_start,
existing_round.started_at, existing_round.started_at,
existing_round.next_playable_at, existing_round.next_playable_at,
@ -230,7 +248,17 @@ begin
existing_round.won, existing_round.won,
existing_round.guess_count, existing_round.guess_count,
now(), now(),
true; true,
case when existing_round.completed_at is null then null else existing_round.word end,
coalesce((
select jsonb_agg(jsonb_build_object(
'guess', wordle_guesses.guess,
'rowIndex', wordle_guesses.row_index,
'states', wordle_guesses.states
) order by wordle_guesses.row_index)
from public.wordle_guesses
where wordle_guesses.round_id = existing_round.id
), '[]'::jsonb);
return; return;
end if; end if;
@ -240,7 +268,6 @@ begin
return query select return query select
new_round.id, new_round.id,
new_round.word,
new_round.hour_start, new_round.hour_start,
new_round.started_at, new_round.started_at,
new_round.next_playable_at, new_round.next_playable_at,
@ -248,10 +275,461 @@ begin
new_round.won, new_round.won,
new_round.guess_count, new_round.guess_count,
now(), now(),
false; false,
null::text,
'[]'::jsonb;
end; end;
$$; $$;
drop function if exists public.submit_guess(uuid, text);
create or replace function public.submit_guess(target_round_id uuid, submitted_guess text)
returns table (
guess text,
row_index integer,
states jsonb,
completed boolean,
won boolean,
guess_count integer,
revealed_word text,
next_playable_at timestamptz,
server_now timestamptz
)
language plpgsql
security definer
set search_path = public
as $$
declare
active_round public.wordle_rounds%rowtype;
normalized_guess text := lower(trim(submitted_guess));
next_row integer;
scored_states jsonb;
did_win boolean;
did_complete boolean;
begin
if auth.uid() is null then
raise exception 'Authentication required';
end if;
if normalized_guess !~ '^[a-z]{5}$' then
raise exception 'Guess must be five letters';
end if;
if not exists (select 1 from public.wordle_dictionary where word = normalized_guess) then
raise exception 'Not in word list';
end if;
select *
into active_round
from public.wordle_rounds rounds
where rounds.id = target_round_id
and rounds.user_id = auth.uid()
limit 1;
if not found then
raise exception 'Round not found';
end if;
if active_round.completed_at is not null then
raise exception 'Round already complete';
end if;
select (count(*) + 1)::integer
into next_row
from public.wordle_guesses
where wordle_guesses.round_id = active_round.id;
if next_row > 6 then
raise exception 'No guesses remaining';
end if;
scored_states := public.score_guess(normalized_guess, active_round.word);
did_win := normalized_guess = active_round.word;
did_complete := did_win or next_row = 6;
insert into public.wordle_guesses (round_id, user_id, guess, row_index, states)
values (active_round.id, auth.uid(), normalized_guess, next_row, scored_states);
if did_complete then
update public.wordle_rounds
set completed_at = now(),
won = did_win,
guess_count = next_row
where id = active_round.id;
end if;
return query select
normalized_guess,
next_row,
scored_states,
did_complete,
did_win,
case when did_complete then next_row else null end,
case when did_complete then active_round.word else null end,
active_round.next_playable_at,
now();
end;
$$;
create or replace function public.get_user_stats()
returns table (
played integer,
wins integer,
current_streak integer,
max_streak integer,
distribution integer[]
)
language plpgsql
security definer
set search_path = public
as $$
declare
round_record record;
running_streak integer := 0;
previous_hour timestamptz;
begin
if auth.uid() is null then
raise exception 'Authentication required';
end if;
played := 0;
wins := 0;
current_streak := 0;
max_streak := 0;
distribution := array[0, 0, 0, 0, 0, 0];
for round_record in
select won, guess_count, hour_start
from public.wordle_rounds
where user_id = auth.uid()
and completed_at is not null
order by hour_start asc
loop
played := played + 1;
if round_record.won then
wins := wins + 1;
running_streak := case
when running_streak > 0 and previous_hour is not null and round_record.hour_start = previous_hour + interval '1 hour'
then running_streak + 1
else 1
end;
max_streak := greatest(max_streak, running_streak);
if round_record.guess_count between 1 and 6 then
distribution[round_record.guess_count] := distribution[round_record.guess_count] + 1;
end if;
else
running_streak := 0;
end if;
previous_hour := round_record.hour_start;
end loop;
previous_hour := null;
for round_record in
select won, hour_start
from public.wordle_rounds
where user_id = auth.uid()
and completed_at is not null
order by hour_start desc
loop
if not round_record.won then
exit;
end if;
if previous_hour is not null and round_record.hour_start <> previous_hour - interval '1 hour' then
exit;
end if;
current_streak := current_streak + 1;
previous_hour := round_record.hour_start;
end loop;
return next;
end;
$$;
create or replace function public.is_username_available(candidate_username text)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
normalized_username text := nullif(lower(regexp_replace(trim(candidate_username), '[^a-z0-9_]', '', 'g')), '');
begin
if normalized_username is null then
return false;
end if;
return not exists (
select 1
from public.profiles
where lower(username) = normalized_username
);
end;
$$;
create or replace function public.get_player_current_streak(target_user_id uuid)
returns integer
language plpgsql
security definer
set search_path = public
as $$
declare
round_record record;
streak integer := 0;
previous_hour timestamptz;
begin
for round_record in
select won, hour_start
from public.wordle_rounds
where user_id = target_user_id
and completed_at is not null
order by hour_start desc
loop
if not round_record.won then
exit;
end if;
if previous_hour is not null and round_record.hour_start <> previous_hour - interval '1 hour' then
exit;
end if;
streak := streak + 1;
previous_hour := round_record.hour_start;
end loop;
return streak;
end;
$$;
create or replace function public.get_hourly_leaderboard()
returns table (
username text,
won boolean,
guess_count integer,
completed_at timestamptz,
rank integer
)
language plpgsql
security definer
set search_path = public
as $$
begin
return query
select
coalesce(profiles.username, profiles.display_name, 'player') as username,
rounds.won,
rounds.guess_count,
rounds.completed_at,
row_number() over (
order by
rounds.won desc,
rounds.guess_count asc nulls last,
rounds.completed_at asc
)::integer as rank
from public.wordle_rounds rounds
left join public.profiles profiles on profiles.id = rounds.user_id
where rounds.hour_start = date_trunc('hour', now())
and rounds.completed_at is not null
order by rank
limit 25;
end;
$$;
create or replace function public.get_leaderboard(board_scope text default 'hour')
returns table (
username text,
won boolean,
guess_count integer,
completed_at timestamptz,
rank integer,
current_streak integer,
is_current_user boolean,
wins integer,
played integer,
average_guesses numeric
)
language plpgsql
security definer
set search_path = public
as $$
declare
normalized_scope text := lower(coalesce(board_scope, 'hour'));
begin
if normalized_scope = 'today' then
return query
with aggregate_rows as (
select
rounds.user_id,
coalesce(profiles.username, profiles.display_name, 'player') as username,
count(*)::integer as played,
count(*) filter (where rounds.won)::integer as wins,
round(avg(rounds.guess_count) filter (where rounds.won), 2) as average_guesses,
min(rounds.completed_at) filter (where rounds.won) as first_win_at,
max(rounds.completed_at) as latest_completed_at
from public.wordle_rounds rounds
left join public.profiles profiles on profiles.id = rounds.user_id
where rounds.completed_at is not null
and rounds.completed_at >= date_trunc('day', now())
group by rounds.user_id, profiles.username, profiles.display_name
), ranked as (
select
aggregate_rows.*,
row_number() over (
order by aggregate_rows.wins desc, aggregate_rows.average_guesses asc nulls last, aggregate_rows.first_win_at asc nulls last
)::integer as row_rank
from aggregate_rows
)
select
ranked.username,
ranked.wins > 0,
null::integer,
ranked.latest_completed_at,
ranked.row_rank,
public.get_player_current_streak(ranked.user_id),
auth.uid() = ranked.user_id,
ranked.wins,
ranked.played,
ranked.average_guesses
from ranked
where ranked.row_rank <= 25 or auth.uid() = ranked.user_id
order by ranked.row_rank;
return;
end if;
if normalized_scope = 'all' then
return query
with aggregate_rows as (
select
rounds.user_id,
coalesce(profiles.username, profiles.display_name, 'player') as username,
count(*)::integer as played,
count(*) filter (where rounds.won)::integer as wins,
round(avg(rounds.guess_count) filter (where rounds.won), 2) as average_guesses,
min(rounds.completed_at) filter (where rounds.won) as first_win_at,
max(rounds.completed_at) as latest_completed_at
from public.wordle_rounds rounds
left join public.profiles profiles on profiles.id = rounds.user_id
where rounds.completed_at is not null
group by rounds.user_id, profiles.username, profiles.display_name
), ranked as (
select
aggregate_rows.*,
row_number() over (
order by aggregate_rows.wins desc, public.get_player_current_streak(aggregate_rows.user_id) desc, aggregate_rows.average_guesses asc nulls last, aggregate_rows.first_win_at asc nulls last
)::integer as row_rank
from aggregate_rows
)
select
ranked.username,
ranked.wins > 0,
null::integer,
ranked.latest_completed_at,
ranked.row_rank,
public.get_player_current_streak(ranked.user_id),
auth.uid() = ranked.user_id,
ranked.wins,
ranked.played,
ranked.average_guesses
from ranked
where ranked.row_rank <= 25 or auth.uid() = ranked.user_id
order by ranked.row_rank;
return;
end if;
return query
with ranked as (
select
rounds.user_id,
coalesce(profiles.username, profiles.display_name, 'player') as username,
rounds.won,
rounds.guess_count,
rounds.completed_at,
row_number() over (
order by rounds.won desc, rounds.guess_count asc nulls last, rounds.completed_at asc
)::integer as row_rank
from public.wordle_rounds rounds
left join public.profiles profiles on profiles.id = rounds.user_id
where rounds.hour_start = date_trunc('hour', now())
and rounds.completed_at is not null
)
select
ranked.username,
ranked.won,
ranked.guess_count,
ranked.completed_at,
ranked.row_rank,
public.get_player_current_streak(ranked.user_id),
auth.uid() = ranked.user_id,
case when ranked.won then 1 else 0 end,
1,
ranked.guess_count::numeric
from ranked
where ranked.row_rank <= 25 or auth.uid() = ranked.user_id
order by ranked.row_rank;
end;
$$;
create or replace function public.get_player_history(history_limit integer default 10)
returns table (
hour_start timestamptz,
word text,
won boolean,
guess_count integer,
completed_at timestamptz
)
language plpgsql
security definer
set search_path = public
as $$
begin
if auth.uid() is null then
raise exception 'Authentication required';
end if;
return query
select
rounds.hour_start,
rounds.word,
rounds.won,
rounds.guess_count,
rounds.completed_at
from public.wordle_rounds rounds
where rounds.user_id = auth.uid()
and rounds.completed_at is not null
order by rounds.hour_start desc
limit greatest(1, least(coalesce(history_limit, 10), 25));
end;
$$;
create or replace function public.get_hourly_summary()
returns table (
completed_count integer,
win_rate integer,
average_guesses numeric
)
language plpgsql
security definer
set search_path = public
as $$
begin
return query
select
count(*)::integer,
coalesce(round((count(*) filter (where won)::numeric / nullif(count(*), 0)) * 100), 0)::integer,
round(avg(guess_count) filter (where won), 2)
from public.wordle_rounds
where hour_start = date_trunc('hour', now())
and completed_at is not null;
end;
$$;
drop function if exists public.get_email_for_username(text);
create or replace function public.complete_hourly_round( create or replace function public.complete_hourly_round(
round_id uuid, round_id uuid,
did_win boolean, did_win boolean,
@ -263,25 +741,25 @@ security definer
set search_path = public set search_path = public
as $$ as $$
begin begin
if auth.uid() is null then raise exception 'complete_hourly_round is deprecated; use submit_guess';
raise exception 'Authentication required';
end if;
if guess_total < 1 or guess_total > 6 then
raise exception 'Guess total must be between 1 and 6';
end if;
update public.wordle_rounds
set completed_at = coalesce(completed_at, now()),
won = did_win,
guess_count = guess_total
where id = round_id
and user_id = auth.uid();
end; end;
$$; $$;
revoke all on public.wordle_rounds from anon, authenticated;
revoke all on public.wordle_words from anon, authenticated; revoke all on public.wordle_words from anon, authenticated;
revoke all on public.wordle_dictionary from anon, authenticated;
revoke all on public.profiles from anon, authenticated;
revoke all on public.wordle_rounds from anon, authenticated;
revoke all on public.wordle_guesses from anon, authenticated;
grant select, update on public.profiles to authenticated;
grant select on public.wordle_rounds to authenticated; grant select on public.wordle_rounds to authenticated;
grant select on public.wordle_guesses to authenticated;
grant execute on function public.is_username_available(text) to anon, authenticated;
grant execute on function public.start_hourly_round() to authenticated; grant execute on function public.start_hourly_round() to authenticated;
grant execute on function public.submit_guess(uuid, text) to authenticated;
grant execute on function public.get_user_stats() to authenticated;
grant execute on function public.get_hourly_leaderboard() to anon, authenticated;
grant execute on function public.get_leaderboard(text) to anon, authenticated;
grant execute on function public.get_player_history(integer) to authenticated;
grant execute on function public.get_hourly_summary() to anon, authenticated;
grant execute on function public.complete_hourly_round(uuid, boolean, integer) to authenticated; grant execute on function public.complete_hourly_round(uuid, boolean, integer) to authenticated;

15300
supabase/seed-word-data.sql Normal file

File diff suppressed because it is too large Load Diff