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,
|
.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%); }
|
||||||
|
|||||||
18
README.md
18
README.md
@ -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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
68
index.html
68
index.html
@ -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
785
script.js
@ -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() {
|
||||||
|
|||||||
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 = {
|
window.FANCY_WORDLE_SUPABASE = {
|
||||||
url: "",
|
url: "https://bxrrirbaucroteyumeul.supabase.co",
|
||||||
anonKey: ""
|
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}$')
|
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
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