Update Version (Beta): login + leaderboard
This commit is contained in:
parent
3175a4dbf1
commit
66f8207985
273
CSS/styles.css
273
CSS/styles.css
@ -124,6 +124,7 @@ a {
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.auth-button,
|
||||
.button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
@ -143,6 +144,7 @@ a {
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.auth-button:hover,
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in oklch, var(--accent), var(--border) 45%);
|
||||
@ -452,6 +454,145 @@ h2 {
|
||||
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 {
|
||||
gap: 10px;
|
||||
}
|
||||
@ -480,6 +621,16 @@ h2 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
padding: 0 14px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
@ -489,6 +640,128 @@ h2 {
|
||||
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 {
|
||||
10%, 90% { transform: translateX(-4%); }
|
||||
30%, 70% { transform: translateX(5%); }
|
||||
|
||||
18
README.md
18
README.md
@ -4,7 +4,9 @@ A Wordle-style game with one shared word for every UTC hour.
|
||||
|
||||
## 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
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
|
||||
<script src="word-data.js?v=5" 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>
|
||||
<body>
|
||||
<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">
|
||||
<span aria-hidden="true">↗</span>
|
||||
</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">
|
||||
<span aria-hidden="true">♡</span>
|
||||
</a>
|
||||
@ -66,12 +70,74 @@
|
||||
<button class="icon-button close-button" type="submit" aria-label="Close stats">×</button>
|
||||
</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="history-panel">
|
||||
<h3>Recent history</h3>
|
||||
<div class="history-list" id="history-list"></div>
|
||||
</div>
|
||||
<menu class="modal-actions">
|
||||
<button type="button" class="button secondary" id="reset-stats">Reset stats</button>
|
||||
<button type="button" class="button primary" id="share-results">Share result</button>
|
||||
</menu>
|
||||
</form>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
68
index.html
68
index.html
@ -10,7 +10,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
|
||||
<script src="word-data.js?v=5" 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>
|
||||
<body>
|
||||
<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">
|
||||
<span aria-hidden="true">↗</span>
|
||||
</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">
|
||||
<span aria-hidden="true">♡</span>
|
||||
</a>
|
||||
@ -66,12 +70,74 @@
|
||||
<button class="icon-button close-button" type="submit" aria-label="Close stats">×</button>
|
||||
</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="history-panel">
|
||||
<h3>Recent history</h3>
|
||||
<div class="history-list" id="history-list"></div>
|
||||
</div>
|
||||
<menu class="modal-actions">
|
||||
<button type="button" class="button secondary" id="reset-stats">Reset stats</button>
|
||||
<button type="button" class="button primary" id="share-results">Share result</button>
|
||||
</menu>
|
||||
</form>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
785
script.js
785
script.js
@ -12,6 +12,10 @@ let isAnimating = false
|
||||
let hourlyRound = null
|
||||
let lockCountdownTimer = null
|
||||
let supabaseClient = null
|
||||
let authSession = null
|
||||
let authProfile = null
|
||||
let leaderboardScope = "hour"
|
||||
let latestLeaderboardRows = []
|
||||
|
||||
const WORD_LENGTH = 5
|
||||
const MAX_GUESSES = 6
|
||||
@ -66,9 +70,33 @@ const roundStatus = document.getElementById("round-status")
|
||||
const triesStatus = document.getElementById("tries-status")
|
||||
const themeButton = document.getElementById("theme-button")
|
||||
const statsButton = document.getElementById("Stats-button")
|
||||
const leaderboardButton = document.getElementById("leaderboard-button")
|
||||
const leaderboardModal = document.getElementById("leaderboard-modal")
|
||||
const leaderboardList = document.getElementById("leaderboard-list")
|
||||
const leaderboardCountdown = document.getElementById("leaderboard-countdown")
|
||||
const leaderboardTabs = document.querySelectorAll("[data-leaderboard-scope]")
|
||||
const authButton = document.getElementById("auth-button")
|
||||
const authModal = document.getElementById("auth-modal")
|
||||
const authForm = document.getElementById("auth-form")
|
||||
const accountSummary = document.getElementById("account-summary")
|
||||
const authCreateFields = document.getElementById("auth-create-fields")
|
||||
const authUsernameInput = document.getElementById("auth-username")
|
||||
const authEmailInput = document.getElementById("auth-email")
|
||||
const authPasswordInput = document.getElementById("auth-password")
|
||||
const authNewPasswordInput = document.getElementById("auth-new-password")
|
||||
const authSignUpButton = document.getElementById("auth-sign-up")
|
||||
const authSignInButton = document.getElementById("auth-sign-in")
|
||||
const authForgotPasswordButton = document.getElementById("auth-forgot-password")
|
||||
const authUpdatePasswordButton = document.getElementById("auth-update-password")
|
||||
const authResetFields = document.getElementById("auth-reset-fields")
|
||||
const authGuestButton = document.getElementById("auth-guest")
|
||||
const authSignOutButton = document.getElementById("auth-sign-out")
|
||||
const authStatus = document.getElementById("auth-status")
|
||||
const statsModal = document.getElementById("stats-modal")
|
||||
const statsGrid = document.getElementById("stats-grid")
|
||||
const statsNote = document.getElementById("stats-note")
|
||||
const guessBars = document.getElementById("guess-bars")
|
||||
const historyList = document.getElementById("history-list")
|
||||
const resetStatsButton = document.getElementById("reset-stats")
|
||||
const shareResultsButton = document.getElementById("share-results")
|
||||
|
||||
@ -86,6 +114,7 @@ async function initializeGame() {
|
||||
createKeyboard()
|
||||
restoreTheme()
|
||||
bindControls()
|
||||
await initializeAuth()
|
||||
|
||||
try {
|
||||
await loadWordLists()
|
||||
@ -167,6 +196,95 @@ function normalizeWords(words) {
|
||||
.filter(word => word.length === WORD_LENGTH)
|
||||
}
|
||||
|
||||
async function initializeAuth() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client) {
|
||||
updateAuthControls()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: { session } } = await client.auth.getSession()
|
||||
authSession = session
|
||||
authProfile = session ? await getAuthProfile() : null
|
||||
updateAuthControls()
|
||||
|
||||
client.auth.onAuthStateChange(async (event, session) => {
|
||||
authSession = session
|
||||
authProfile = session ? await getAuthProfile() : null
|
||||
updateAuthControls()
|
||||
|
||||
if (event === "PASSWORD_RECOVERY") {
|
||||
openAuth()
|
||||
setPasswordResetMode(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (event === "SIGNED_IN") window.location.reload()
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("Supabase auth unavailable:", error)
|
||||
updateAuthControls()
|
||||
}
|
||||
}
|
||||
|
||||
async function getAuthProfile() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client || !authSession) return null
|
||||
|
||||
const { data, error } = await client
|
||||
.from("profiles")
|
||||
.select("username, display_name")
|
||||
.eq("id", authSession.user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
console.warn("Failed to load profile:", error)
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
function updateAuthControls() {
|
||||
const hasSupabase = Boolean(getSupabaseClient())
|
||||
const label = getAuthLabel()
|
||||
|
||||
if (authButton) {
|
||||
authButton.textContent = label
|
||||
authButton.title = authSession ? "Account" : "Sign in"
|
||||
authButton.setAttribute("aria-label", authSession ? "Open account" : "Sign in to sync stats")
|
||||
}
|
||||
|
||||
if (authStatus) {
|
||||
authStatus.textContent = hasSupabase
|
||||
? authSession
|
||||
? `Signed in as ${label}`
|
||||
: "Sign in with your email and password. If we cannot find your account, you can create one here."
|
||||
: "Supabase is not configured. Guest play is active."
|
||||
}
|
||||
|
||||
if (accountSummary) {
|
||||
accountSummary.hidden = !authSession
|
||||
accountSummary.innerHTML = authSession
|
||||
? `<strong>${escapeHtml(label)}</strong><span>${escapeHtml(authSession.user.email || "Signed in")}</span>`
|
||||
: ""
|
||||
}
|
||||
|
||||
if (authForm) authForm.hidden = !hasSupabase || Boolean(authSession)
|
||||
if (authGuestButton) authGuestButton.hidden = Boolean(authSession)
|
||||
if (authSignOutButton) authSignOutButton.hidden = !authSession
|
||||
}
|
||||
|
||||
function getAuthLabel() {
|
||||
if (!authSession) return "Sign in"
|
||||
|
||||
return authProfile?.username
|
||||
|| authProfile?.display_name
|
||||
|| authSession.user.email
|
||||
|| "Account"
|
||||
}
|
||||
|
||||
async function startHourlyRound() {
|
||||
const selectedWord = getCurrentHourlyWord()
|
||||
const remoteRound = await startRemoteHourlyRound()
|
||||
@ -177,10 +295,9 @@ async function startHourlyRound() {
|
||||
|
||||
async function startRemoteHourlyRound() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client) return null
|
||||
if (!client || !authSession) return null
|
||||
|
||||
try {
|
||||
await ensureSupabaseSession(client)
|
||||
const { data, error } = await client.rpc("start_hourly_round")
|
||||
if (error) throw error
|
||||
|
||||
@ -206,29 +323,32 @@ function getSupabaseClient() {
|
||||
return supabaseClient
|
||||
}
|
||||
|
||||
async function ensureSupabaseSession(client) {
|
||||
const { data: { session } } = await client.auth.getSession()
|
||||
if (session) return
|
||||
|
||||
const { error } = await client.auth.signInAnonymously()
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
function normalizeRemoteRound(row) {
|
||||
return {
|
||||
id: row.round_id,
|
||||
backend: "supabase",
|
||||
word: row.word,
|
||||
word: row.revealed_word || "",
|
||||
hourStart: Date.parse(row.hour_start),
|
||||
startedAt: Date.parse(row.started_at),
|
||||
nextPlayableAt: Date.parse(row.next_playable_at),
|
||||
completedAt: row.completed_at ? Date.parse(row.completed_at) : null,
|
||||
won: row.won,
|
||||
guessCount: row.guess_count,
|
||||
isExisting: row.is_existing
|
||||
isExisting: row.is_existing,
|
||||
guesses: normalizeRemoteGuesses(row.guesses)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRemoteGuesses(guesses) {
|
||||
if (!Array.isArray(guesses)) return []
|
||||
|
||||
return guesses.map(guess => ({
|
||||
guess: guess.guess,
|
||||
rowIndex: guess.rowIndex,
|
||||
states: guess.states
|
||||
}))
|
||||
}
|
||||
|
||||
function startLocalHourlyRound(selectedWord) {
|
||||
const savedRound = getLocalHourlyRound()
|
||||
if (savedRound && savedRound.nextPlayableAt > Date.now()) return savedRound
|
||||
@ -287,16 +407,17 @@ function getCurrentHourStart(timestamp) {
|
||||
|
||||
function applyHourlyRound(round) {
|
||||
hourlyRound = round
|
||||
targetWord = round.word
|
||||
dictionarySet.add(targetWord)
|
||||
currentGuessIndex = round.completedAt ? round.guessCount || 0 : 0
|
||||
targetWord = round.word || ""
|
||||
if (targetWord) dictionarySet.add(targetWord)
|
||||
restoreGuesses(round.guesses || [])
|
||||
currentGuessIndex = round.completedAt ? round.guessCount || currentGuessIndex : currentGuessIndex
|
||||
updateTriesStatus()
|
||||
|
||||
if (round.completedAt) {
|
||||
lastResult = {
|
||||
won: Boolean(round.won),
|
||||
guesses: round.guessCount || MAX_GUESSES,
|
||||
word: round.word
|
||||
word: round.word || ""
|
||||
}
|
||||
lockUntilNextWord()
|
||||
return
|
||||
@ -306,6 +427,33 @@ function applyHourlyRound(round) {
|
||||
startInteraction()
|
||||
}
|
||||
|
||||
function restoreGuesses(guesses) {
|
||||
if (!Array.isArray(guesses) || guesses.length === 0) {
|
||||
currentGuessIndex = 0
|
||||
return
|
||||
}
|
||||
|
||||
guesses.forEach(savedGuess => {
|
||||
const rowIndex = Number(savedGuess.rowIndex) || 0
|
||||
const rowStart = (rowIndex - 1) * WORD_LENGTH
|
||||
const states = Array.isArray(savedGuess.states) ? savedGuess.states : []
|
||||
|
||||
String(savedGuess.guess || "").split("").forEach((letter, index) => {
|
||||
const tile = guessGrid.children[rowStart + index]
|
||||
if (!tile) return
|
||||
|
||||
const state = states[index] || "wrong"
|
||||
tile.dataset.letter = letter
|
||||
tile.textContent = letter
|
||||
tile.dataset.state = state
|
||||
tile.setAttribute("aria-label", `${letter}, ${readableState(state)}`)
|
||||
updateKeyState(keyboard.querySelector(`[data-key="${letter}"i]`), state)
|
||||
})
|
||||
})
|
||||
|
||||
currentGuessIndex = Math.min(guesses.length, MAX_GUESSES)
|
||||
}
|
||||
|
||||
function createBoard() {
|
||||
guessGrid.innerHTML = ""
|
||||
|
||||
@ -364,6 +512,20 @@ function createActionKey(label, ariaLabel, attribute) {
|
||||
function bindControls() {
|
||||
themeButton.addEventListener("click", toggleTheme)
|
||||
statsButton.addEventListener("click", openStats)
|
||||
leaderboardButton?.addEventListener("click", openLeaderboard)
|
||||
leaderboardTabs.forEach(tab => {
|
||||
tab.addEventListener("click", () => switchLeaderboardScope(tab.dataset.leaderboardScope))
|
||||
})
|
||||
authButton?.addEventListener("click", openAuth)
|
||||
authSignUpButton?.addEventListener("click", signUpWithPassword)
|
||||
authSignInButton?.addEventListener("click", signInWithPassword)
|
||||
authForgotPasswordButton?.addEventListener("click", sendPasswordReset)
|
||||
authUpdatePasswordButton?.addEventListener("click", updatePassword)
|
||||
authForm?.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter") signInWithPassword(event)
|
||||
})
|
||||
authGuestButton?.addEventListener("click", () => authModal.close())
|
||||
authSignOutButton?.addEventListener("click", signOut)
|
||||
resetStatsButton.addEventListener("click", resetStats)
|
||||
shareResultsButton.addEventListener("click", shareResult)
|
||||
document.querySelector(".brand").addEventListener("click", event => {
|
||||
@ -372,6 +534,176 @@ function bindControls() {
|
||||
})
|
||||
}
|
||||
|
||||
function openAuth() {
|
||||
updateAuthControls()
|
||||
if (!authSession) {
|
||||
setCreateAccountMode(false)
|
||||
setPasswordResetMode(false)
|
||||
}
|
||||
authModal?.showModal()
|
||||
}
|
||||
|
||||
async function signUpWithPassword(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const client = getSupabaseClient()
|
||||
const email = authEmailInput?.value.trim()
|
||||
const password = authPasswordInput?.value
|
||||
const username = normalizeUsername(authUsernameInput?.value)
|
||||
if (!client || !email || !password || !username) {
|
||||
authStatus.textContent = "Username, email, and password are required."
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
authStatus.textContent = "Password must be at least 6 characters."
|
||||
return
|
||||
}
|
||||
|
||||
const isAvailable = await isUsernameAvailable(username)
|
||||
if (!isAvailable) {
|
||||
authStatus.textContent = "That username is already taken. Try another one."
|
||||
return
|
||||
}
|
||||
|
||||
authStatus.textContent = "Creating account..."
|
||||
const { error } = await client.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
username,
|
||||
display_name: username
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
authStatus.textContent = error
|
||||
? readableAuthError(error)
|
||||
: "Account created. Check your email if confirmation is required."
|
||||
}
|
||||
|
||||
async function signInWithPassword(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const client = getSupabaseClient()
|
||||
const email = authEmailInput?.value.trim()
|
||||
const password = authPasswordInput?.value
|
||||
if (!client || !email || !password) {
|
||||
authStatus.textContent = "Email and password are required."
|
||||
return
|
||||
}
|
||||
|
||||
authStatus.textContent = "Signing in..."
|
||||
const { error } = await client.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setCreateAccountMode(true)
|
||||
authStatus.textContent = `${readableAuthError(error)} Create an account below if this is your first time.`
|
||||
}
|
||||
}
|
||||
|
||||
async function isUsernameAvailable(username) {
|
||||
const client = getSupabaseClient()
|
||||
if (!client) return false
|
||||
|
||||
const { data, error } = await client.rpc("is_username_available", {
|
||||
candidate_username: username
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.warn("Failed to check username:", error)
|
||||
return false
|
||||
}
|
||||
|
||||
return Boolean(data)
|
||||
}
|
||||
|
||||
async function sendPasswordReset() {
|
||||
const client = getSupabaseClient()
|
||||
const email = authEmailInput?.value.trim()
|
||||
if (!client || !email) {
|
||||
authStatus.textContent = "Enter your email first, then request a reset link."
|
||||
return
|
||||
}
|
||||
|
||||
authStatus.textContent = "Sending password reset..."
|
||||
const { error } = await client.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: window.location.href
|
||||
})
|
||||
|
||||
authStatus.textContent = error
|
||||
? readableAuthError(error)
|
||||
: "Password reset link sent. Check your email."
|
||||
}
|
||||
|
||||
async function updatePassword() {
|
||||
const client = getSupabaseClient()
|
||||
const password = authNewPasswordInput?.value
|
||||
if (!client || !password || password.length < 6) {
|
||||
authStatus.textContent = "New password must be at least 6 characters."
|
||||
return
|
||||
}
|
||||
|
||||
const { error } = await client.auth.updateUser({ password })
|
||||
authStatus.textContent = error
|
||||
? readableAuthError(error)
|
||||
: "Password updated. You can keep playing."
|
||||
if (!error) setPasswordResetMode(false)
|
||||
}
|
||||
|
||||
function setCreateAccountMode(isVisible) {
|
||||
if (authCreateFields) authCreateFields.hidden = !isVisible
|
||||
if (authSignUpButton) authSignUpButton.hidden = !isVisible
|
||||
if (authStatus && isVisible) {
|
||||
authStatus.textContent = "New here? Choose a username, keep the same email/password, and create your account."
|
||||
}
|
||||
}
|
||||
|
||||
function setPasswordResetMode(isVisible) {
|
||||
if (authResetFields) authResetFields.hidden = !isVisible
|
||||
if (authForgotPasswordButton) authForgotPasswordButton.hidden = isVisible
|
||||
if (authStatus && isVisible) authStatus.textContent = "Choose a new password for your account."
|
||||
}
|
||||
|
||||
function readableAuthError(error) {
|
||||
const message = String(error?.message || "Authentication failed.")
|
||||
const lowerMessage = message.toLowerCase()
|
||||
|
||||
if (lowerMessage.includes("invalid login") || lowerMessage.includes("invalid credentials")) {
|
||||
return "Email or password is incorrect."
|
||||
}
|
||||
if (lowerMessage.includes("already registered") || lowerMessage.includes("already exists")) {
|
||||
return "That email already has an account. Sign in instead."
|
||||
}
|
||||
if (lowerMessage.includes("password")) {
|
||||
return "Password must be at least 6 characters."
|
||||
}
|
||||
if (lowerMessage.includes("rate limit")) {
|
||||
return "Too many attempts. Wait a few minutes and try again."
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
function normalizeUsername(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, "")
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
const client = getSupabaseClient()
|
||||
if (!client) return
|
||||
|
||||
await client.auth.signOut()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function startInteraction() {
|
||||
stopInteraction()
|
||||
document.addEventListener("click", handleMouseClick)
|
||||
@ -401,7 +733,7 @@ function handleMouseClick(event) {
|
||||
}
|
||||
|
||||
function handleKeyPress(event) {
|
||||
if (statsModal.open) return
|
||||
if (statsModal.open || authModal?.open) return
|
||||
|
||||
if (event.key === "Enter") {
|
||||
submitGuess()
|
||||
@ -449,7 +781,7 @@ function deleteKey() {
|
||||
lastTile.setAttribute("aria-label", "Empty letter")
|
||||
}
|
||||
|
||||
function submitGuess() {
|
||||
async function submitGuess() {
|
||||
if (gameFinished || isAnimating) return
|
||||
|
||||
const activeTiles = getCurrentRowTiles().filter(tile => tile.dataset.letter)
|
||||
@ -469,15 +801,93 @@ function submitGuess() {
|
||||
stopInteraction()
|
||||
isAnimating = true
|
||||
roundStatus.textContent = "Checking guess…"
|
||||
currentGuessIndex += 1
|
||||
|
||||
const result = await resolveGuess(guess, activeTiles)
|
||||
if (!result) return
|
||||
|
||||
const states = normalizeGuessStates(result.states)
|
||||
if (states.length !== WORD_LENGTH) {
|
||||
recoverFromGuessError(activeTiles, "Guess could not be scored")
|
||||
return
|
||||
}
|
||||
|
||||
currentGuessIndex = result.rowIndex
|
||||
updateTriesStatus()
|
||||
|
||||
const states = scoreGuess(guess, targetWord)
|
||||
activeTiles.forEach((tile, index, tiles) => {
|
||||
flipTile(tile, index, tiles, guess, states[index])
|
||||
flipTile(tile, index, tiles, guess, states[index], { ...result, states })
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveGuess(guess, activeTiles) {
|
||||
if (hourlyRound?.backend === "supabase") {
|
||||
try {
|
||||
return await submitRemoteGuess(guess)
|
||||
} catch (error) {
|
||||
console.warn("Failed to submit guess:", error)
|
||||
recoverFromGuessError(activeTiles, error.message || "Guess could not be saved")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const rowIndex = currentGuessIndex + 1
|
||||
return {
|
||||
rowIndex,
|
||||
states: scoreGuess(guess, targetWord),
|
||||
completed: guess === targetWord || rowIndex === MAX_GUESSES,
|
||||
won: guess === targetWord,
|
||||
revealedWord: targetWord
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRemoteGuess(guess) {
|
||||
const client = getSupabaseClient()
|
||||
if (!client || !hourlyRound?.id) throw new Error("Sign in again to save this guess")
|
||||
|
||||
const { data, error } = await client.rpc("submit_guess", {
|
||||
target_round_id: hourlyRound.id,
|
||||
submitted_guess: guess
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const row = Array.isArray(data) ? data[0] : data
|
||||
if (!row) throw new Error("No result returned")
|
||||
|
||||
return {
|
||||
rowIndex: row.row_index,
|
||||
states: normalizeGuessStates(row.states),
|
||||
completed: row.completed,
|
||||
won: row.won,
|
||||
guessCount: row.guess_count,
|
||||
revealedWord: row.revealed_word,
|
||||
nextPlayableAt: Date.parse(row.next_playable_at)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGuessStates(states) {
|
||||
if (Array.isArray(states)) return states
|
||||
|
||||
if (typeof states === "string") {
|
||||
try {
|
||||
const parsedStates = JSON.parse(states)
|
||||
return Array.isArray(parsedStates) ? parsedStates : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function recoverFromGuessError(activeTiles, message) {
|
||||
showAlert(message)
|
||||
shakeTiles(activeTiles)
|
||||
roundStatus.textContent = "Try again"
|
||||
isAnimating = false
|
||||
startInteraction()
|
||||
}
|
||||
|
||||
function isValidGuess(guess) {
|
||||
if (!/^[a-z]{5}$/.test(guess)) return false
|
||||
return dictionarySet.has(guess)
|
||||
@ -507,7 +917,7 @@ function scoreGuess(guess, answer) {
|
||||
return states
|
||||
}
|
||||
|
||||
function flipTile(tile, index, tiles, guess, state) {
|
||||
function flipTile(tile, index, tiles, guess, state, roundResult) {
|
||||
const letter = tile.dataset.letter
|
||||
const key = keyboard.querySelector(`[data-key="${letter}"i]`)
|
||||
|
||||
@ -525,7 +935,7 @@ function flipTile(tile, index, tiles, guess, state) {
|
||||
setTimeout(() => {
|
||||
currentTileIndex = 0
|
||||
isAnimating = false
|
||||
checkWinLose(guess, tiles)
|
||||
checkWinLose(guess, tiles, roundResult)
|
||||
}, FLIP_ANIMATION_DURATION / 2)
|
||||
}
|
||||
}, FLIP_ANIMATION_DURATION / 2)
|
||||
@ -583,8 +993,21 @@ function shakeTiles(tiles) {
|
||||
})
|
||||
}
|
||||
|
||||
function checkWinLose(guess, tiles) {
|
||||
if (guess === targetWord) {
|
||||
function checkWinLose(guess, tiles, roundResult) {
|
||||
if (roundResult?.revealedWord) {
|
||||
targetWord = roundResult.revealedWord
|
||||
hourlyRound = {
|
||||
...hourlyRound,
|
||||
word: targetWord,
|
||||
completedAt: roundResult.completed ? Date.now() : null,
|
||||
won: roundResult.won,
|
||||
guessCount: roundResult.guessCount || currentGuessIndex,
|
||||
nextPlayableAt: roundResult.nextPlayableAt || hourlyRound.nextPlayableAt
|
||||
}
|
||||
}
|
||||
|
||||
const wonRound = roundResult ? roundResult.completed && roundResult.won : guess === targetWord
|
||||
if (wonRound) {
|
||||
gameFinished = true
|
||||
lastResult = { won: true, guesses: currentGuessIndex, word: targetWord }
|
||||
saveGameResult(lastResult)
|
||||
@ -595,12 +1018,14 @@ function checkWinLose(guess, tiles) {
|
||||
roundStatus.textContent = `Solved in ${currentGuessIndex}`
|
||||
setTimeout(() => showWordDefinition(targetWord, true), 1800)
|
||||
startLockCountdown(2400)
|
||||
showLeaderboardAfterCompletion()
|
||||
stopInteraction()
|
||||
return
|
||||
}
|
||||
|
||||
const remainingTiles = guessGrid.querySelectorAll(":not([data-letter])")
|
||||
if (remainingTiles.length === 0) {
|
||||
const lostRound = roundResult ? roundResult.completed && !roundResult.won : remainingTiles.length === 0
|
||||
if (lostRound) {
|
||||
gameFinished = true
|
||||
lastResult = { won: false, guesses: MAX_GUESSES, word: targetWord }
|
||||
saveGameResult(lastResult)
|
||||
@ -608,6 +1033,7 @@ function checkWinLose(guess, tiles) {
|
||||
roundStatus.textContent = "Round complete"
|
||||
showWordDefinition(targetWord, false)
|
||||
startLockCountdown(2400)
|
||||
showLeaderboardAfterCompletion()
|
||||
stopInteraction()
|
||||
return
|
||||
}
|
||||
@ -620,6 +1046,17 @@ function updateTriesStatus() {
|
||||
triesStatus.textContent = `${currentGuessIndex} / ${MAX_GUESSES}`
|
||||
}
|
||||
|
||||
function showLeaderboardAfterCompletion() {
|
||||
setTimeout(() => {
|
||||
if (statsModal.open || authModal?.open) return
|
||||
leaderboardScope = "hour"
|
||||
leaderboardTabs.forEach(tab => {
|
||||
tab.classList.toggle("active", tab.dataset.leaderboardScope === leaderboardScope)
|
||||
})
|
||||
openLeaderboard()
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
function lockUntilNextWord() {
|
||||
gameFinished = true
|
||||
stopInteraction()
|
||||
@ -665,6 +1102,7 @@ function updateLockCountdown() {
|
||||
|
||||
roundStatus.textContent = `Next word in ${formatRemainingTime(remaining)}`
|
||||
triesStatus.textContent = "Locked"
|
||||
updateLeaderboardCountdown()
|
||||
}
|
||||
|
||||
function formatRemainingTime(milliseconds) {
|
||||
@ -773,11 +1211,26 @@ function toggleTheme() {
|
||||
themeButton.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode")
|
||||
}
|
||||
|
||||
function openStats() {
|
||||
renderStats()
|
||||
async function openStats() {
|
||||
await renderStats()
|
||||
statsModal.showModal()
|
||||
}
|
||||
|
||||
async function openLeaderboard() {
|
||||
renderLeaderboardLoading()
|
||||
leaderboardModal?.showModal()
|
||||
await renderLeaderboard()
|
||||
}
|
||||
|
||||
function switchLeaderboardScope(scope) {
|
||||
leaderboardScope = scope || "hour"
|
||||
leaderboardTabs.forEach(tab => {
|
||||
tab.classList.toggle("active", tab.dataset.leaderboardScope === leaderboardScope)
|
||||
})
|
||||
renderLeaderboardLoading()
|
||||
renderLeaderboard()
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const fallback = {
|
||||
played: 0,
|
||||
@ -799,6 +1252,8 @@ function saveStats(stats) {
|
||||
}
|
||||
|
||||
function saveGameResult(result) {
|
||||
if (hourlyRound?.backend === "supabase") return
|
||||
|
||||
const stats = getStats()
|
||||
stats.played += 1
|
||||
|
||||
@ -812,10 +1267,6 @@ function saveGameResult(result) {
|
||||
}
|
||||
|
||||
saveStats(stats)
|
||||
completeHourlyRound(result)
|
||||
}
|
||||
|
||||
function completeHourlyRound(result) {
|
||||
if (!hourlyRound || hourlyRound.completedAt) return
|
||||
|
||||
hourlyRound = {
|
||||
@ -825,31 +1276,12 @@ function completeHourlyRound(result) {
|
||||
guessCount: result.guesses
|
||||
}
|
||||
|
||||
if (hourlyRound.backend === "local") {
|
||||
saveLocalHourlyRound(hourlyRound)
|
||||
return
|
||||
}
|
||||
|
||||
completeRemoteHourlyRound(result).catch(error => {
|
||||
console.warn("Failed to complete Supabase hourly round:", error)
|
||||
})
|
||||
}
|
||||
|
||||
async function completeRemoteHourlyRound(result) {
|
||||
const client = getSupabaseClient()
|
||||
if (!client || !hourlyRound?.id) return
|
||||
|
||||
const { error } = await client.rpc("complete_hourly_round", {
|
||||
round_id: hourlyRound.id,
|
||||
did_win: result.won,
|
||||
guess_total: result.guesses
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const stats = getStats()
|
||||
async function renderStats() {
|
||||
const stats = await getDisplayStats()
|
||||
const summary = await getHourlySummary()
|
||||
const winRate = stats.played === 0 ? 0 : Math.round((stats.wins / stats.played) * 100)
|
||||
const statItems = [
|
||||
["Played", stats.played],
|
||||
@ -874,16 +1306,237 @@ function renderStats() {
|
||||
`
|
||||
})
|
||||
.join("")
|
||||
|
||||
renderStatsNote(summary)
|
||||
await renderHistory()
|
||||
}
|
||||
|
||||
function 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)
|
||||
renderStats()
|
||||
await renderStats()
|
||||
showAlert("Stats reset")
|
||||
}
|
||||
|
||||
async function shareResult() {
|
||||
const text = buildShareText()
|
||||
const text = await buildShareText()
|
||||
|
||||
try {
|
||||
if (navigator.share) {
|
||||
@ -899,13 +1552,27 @@ async function shareResult() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildShareText() {
|
||||
async function buildShareText() {
|
||||
const status = lastResult
|
||||
? `${lastResult.won ? lastResult.guesses : "X"}/${MAX_GUESSES}`
|
||||
: `${currentGuessIndex}/${MAX_GUESSES}`
|
||||
const roundLabel = getShareRoundLabel()
|
||||
const rankLabel = await getShareRankLabel()
|
||||
|
||||
return `Fancy Wordle ${roundLabel} ${status}\n${getResultGrid()}`
|
||||
return `Fancy Wordle ${roundLabel} ${status}${rankLabel}\n${getResultGrid()}`
|
||||
}
|
||||
|
||||
async function getShareRankLabel() {
|
||||
if (hourlyRound?.backend !== "supabase" || !lastResult) return ""
|
||||
|
||||
let currentUserRow = leaderboardScope === "hour"
|
||||
? latestLeaderboardRows.find(row => row.isCurrentUser)
|
||||
: null
|
||||
if (!currentUserRow) {
|
||||
currentUserRow = (await getLeaderboardRows("hour")).find(row => row.isCurrentUser)
|
||||
}
|
||||
|
||||
return currentUserRow ? ` · #${currentUserRow.rank} this hour` : ""
|
||||
}
|
||||
|
||||
function getShareRoundLabel() {
|
||||
|
||||
40
scripts/generate-supabase-word-seed.mjs
Normal file
40
scripts/generate-supabase-word-seed.mjs
Normal 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.`)
|
||||
@ -1,4 +1,4 @@
|
||||
window.FANCY_WORDLE_SUPABASE = {
|
||||
url: "",
|
||||
anonKey: ""
|
||||
url: "https://bxrrirbaucroteyumeul.supabase.co",
|
||||
anonKey: "sb_publishable_moGEav5v53vMJPwnWWp4tg_Ab67sG4o"
|
||||
}
|
||||
|
||||
@ -5,138 +5,32 @@ create table if not exists public.wordle_words (
|
||||
word text not null unique check (word ~ '^[a-z]{5}$')
|
||||
);
|
||||
|
||||
insert into public.wordle_words (position, word) values
|
||||
(1, 'cigar'),
|
||||
(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;
|
||||
create table if not exists public.wordle_dictionary (
|
||||
word text primary key check (word ~ '^[a-z]{5}$')
|
||||
);
|
||||
|
||||
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 (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
word text not null check (word ~ '^[a-z]{5}$'),
|
||||
hour_start timestamptz,
|
||||
hour_start timestamptz not null,
|
||||
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,
|
||||
won boolean,
|
||||
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
|
||||
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
|
||||
set hour_start = date_trunc('hour', started_at)
|
||||
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 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
|
||||
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
|
||||
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_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;
|
||||
create policy "Users can read their own rounds"
|
||||
@ -167,12 +98,113 @@ create policy "Users can read their own rounds"
|
||||
for select
|
||||
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);
|
||||
|
||||
create or replace function public.start_hourly_round()
|
||||
returns table (
|
||||
round_id uuid,
|
||||
word text,
|
||||
hour_start timestamptz,
|
||||
started_at timestamptz,
|
||||
next_playable_at timestamptz,
|
||||
@ -180,7 +212,9 @@ returns table (
|
||||
won boolean,
|
||||
guess_count integer,
|
||||
server_now timestamptz,
|
||||
is_existing boolean
|
||||
is_existing boolean,
|
||||
revealed_word text,
|
||||
guesses jsonb
|
||||
)
|
||||
language plpgsql
|
||||
security definer
|
||||
@ -190,27 +224,12 @@ declare
|
||||
existing_round public.wordle_rounds%rowtype;
|
||||
new_round public.wordle_rounds%rowtype;
|
||||
current_hour timestamptz := date_trunc('hour', now());
|
||||
word_count integer;
|
||||
word_offset integer;
|
||||
hourly_word text;
|
||||
hourly_word text := public.get_hourly_word(date_trunc('hour', now()));
|
||||
begin
|
||||
if auth.uid() is null then
|
||||
raise exception 'Authentication required';
|
||||
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 *
|
||||
into existing_round
|
||||
from public.wordle_rounds
|
||||
@ -222,7 +241,6 @@ begin
|
||||
if found then
|
||||
return query select
|
||||
existing_round.id,
|
||||
existing_round.word,
|
||||
existing_round.hour_start,
|
||||
existing_round.started_at,
|
||||
existing_round.next_playable_at,
|
||||
@ -230,7 +248,17 @@ begin
|
||||
existing_round.won,
|
||||
existing_round.guess_count,
|
||||
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;
|
||||
end if;
|
||||
|
||||
@ -240,7 +268,6 @@ begin
|
||||
|
||||
return query select
|
||||
new_round.id,
|
||||
new_round.word,
|
||||
new_round.hour_start,
|
||||
new_round.started_at,
|
||||
new_round.next_playable_at,
|
||||
@ -248,10 +275,461 @@ begin
|
||||
new_round.won,
|
||||
new_round.guess_count,
|
||||
now(),
|
||||
false;
|
||||
false,
|
||||
null::text,
|
||||
'[]'::jsonb;
|
||||
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(
|
||||
round_id uuid,
|
||||
did_win boolean,
|
||||
@ -263,25 +741,25 @@ security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
if auth.uid() is null then
|
||||
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();
|
||||
raise exception 'complete_hourly_round is deprecated; use submit_guess';
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on public.wordle_rounds 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_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.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;
|
||||
|
||||
15300
supabase/seed-word-data.sql
Normal file
15300
supabase/seed-word-data.sql
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user