14 Commits

6 changed files with 150 additions and 168 deletions

View File

@@ -2,45 +2,46 @@
# uv pip compile requirements-server.in
annotated-types==0.7.0
# via pydantic
anyio==4.6.2.post1
anyio==4.9.0
# via
# httpx
# starlette
# watchfiles
certifi==2024.8.30
certifi==2025.4.26
# via
# httpcore
# httpx
click==8.1.7
click==8.1.8
# via
# rich-toolkit
# typer
# uvicorn
dnspython==2.7.0
# via email-validator
email-validator==2.2.0
# via fastapi
fastapi==0.115.5
fastapi==0.115.12
# via -r requirements.in
fastapi-cli==0.0.5
fastapi-cli==0.0.7
# via fastapi
gunicorn==23.0.0
# via -r requirements-server.in
h11==0.14.0
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.7
httpcore==1.0.9
# via httpx
httptools==0.6.4
# via uvicorn
httpx==0.27.2
httpx==0.28.1
# via fastapi
idna==3.10
# via
# anyio
# email-validator
# httpx
jinja2==3.1.4
jinja2==3.1.6
# via fastapi
markdown-it-py==3.0.0
# via rich
@@ -48,51 +49,59 @@ markupsafe==3.0.2
# via jinja2
mdurl==0.1.2
# via markdown-it-py
packaging==24.2
packaging==25.0
# via gunicorn
pydantic==2.10.1
pydantic==2.11.4
# via
# fastapi
# pydantic-settings
pydantic-core==2.27.1
pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.6.1
pydantic-settings==2.9.1
# via -r requirements.in
pygments==2.18.0
pygments==2.19.1
# via rich
python-dotenv==1.0.1
python-dotenv==1.1.0
# via
# pydantic-settings
# uvicorn
python-multipart==0.0.17
python-multipart==0.0.20
# via fastapi
pyyaml==6.0.2
# via uvicorn
rich==13.9.4
# via typer
rich==14.0.0
# via
# rich-toolkit
# typer
rich-toolkit==0.14.6
# via fastapi-cli
shellingham==1.5.4
# via typer
sniffio==1.3.1
# via
# anyio
# httpx
starlette==0.41.3
# via anyio
starlette==0.46.2
# via fastapi
typer==0.13.1
typer==0.15.4
# via fastapi-cli
typing-extensions==4.12.2
typing-extensions==4.13.2
# via
# fastapi
# pydantic
# pydantic-core
# rich-toolkit
# typer
uvicorn==0.32.1
# typing-inspection
typing-inspection==0.4.0
# via
# pydantic
# pydantic-settings
uvicorn==0.34.2
# via
# fastapi
# fastapi-cli
uvloop==0.21.0
# via uvicorn
watchfiles==0.24.0
watchfiles==1.0.5
# via uvicorn
websockets==14.1
websockets==15.0.1
# via uvicorn

View File

@@ -2,43 +2,44 @@
# uv pip compile requirements.in
annotated-types==0.7.0
# via pydantic
anyio==4.6.2.post1
anyio==4.9.0
# via
# httpx
# starlette
# watchfiles
certifi==2024.8.30
certifi==2025.4.26
# via
# httpcore
# httpx
click==8.1.7
click==8.1.8
# via
# rich-toolkit
# typer
# uvicorn
dnspython==2.7.0
# via email-validator
email-validator==2.2.0
# via fastapi
fastapi==0.115.5
fastapi==0.115.12
# via -r requirements.in
fastapi-cli==0.0.5
fastapi-cli==0.0.7
# via fastapi
h11==0.14.0
h11==0.16.0
# via
# httpcore
# uvicorn
httpcore==1.0.7
httpcore==1.0.9
# via httpx
httptools==0.6.4
# via uvicorn
httpx==0.27.2
httpx==0.28.1
# via fastapi
idna==3.10
# via
# anyio
# email-validator
# httpx
jinja2==3.1.4
jinja2==3.1.6
# via fastapi
markdown-it-py==3.0.0
# via rich
@@ -46,49 +47,57 @@ markupsafe==3.0.2
# via jinja2
mdurl==0.1.2
# via markdown-it-py
pydantic==2.10.1
pydantic==2.11.4
# via
# fastapi
# pydantic-settings
pydantic-core==2.27.1
pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.6.1
pydantic-settings==2.9.1
# via -r requirements.in
pygments==2.18.0
pygments==2.19.1
# via rich
python-dotenv==1.0.1
python-dotenv==1.1.0
# via
# pydantic-settings
# uvicorn
python-multipart==0.0.17
python-multipart==0.0.20
# via fastapi
pyyaml==6.0.2
# via uvicorn
rich==13.9.4
# via typer
rich==14.0.0
# via
# rich-toolkit
# typer
rich-toolkit==0.14.6
# via fastapi-cli
shellingham==1.5.4
# via typer
sniffio==1.3.1
# via
# anyio
# httpx
starlette==0.41.3
# via anyio
starlette==0.46.2
# via fastapi
typer==0.13.1
typer==0.15.4
# via fastapi-cli
typing-extensions==4.12.2
typing-extensions==4.13.2
# via
# fastapi
# pydantic
# pydantic-core
# rich-toolkit
# typer
uvicorn==0.32.1
# typing-inspection
typing-inspection==0.4.0
# via
# pydantic
# pydantic-settings
uvicorn==0.34.2
# via
# fastapi
# fastapi-cli
uvloop==0.21.0
# via uvicorn
watchfiles==0.24.0
watchfiles==1.0.5
# via uvicorn
websockets==14.1
websockets==15.0.1
# via uvicorn

View File

@@ -2,8 +2,9 @@
import logging
from datetime import date, datetime, timezone
from typing import Union
from zoneinfo import ZoneInfo
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -11,6 +12,11 @@ from pydantic import DirectoryPath, FilePath
from pydantic_settings import BaseSettings
VERSION = '0.3.2'
AMSTERDAM = ZoneInfo('Europe/Amsterdam')
class Settings(BaseSettings):
"""Configuration needed for alfagok to find its word list, using environment variables."""
@@ -51,15 +57,15 @@ if settings.debug:
def get_game_id():
"""Calculate the index for the game/word we are handling today."""
today = datetime.now(timezone.utc).date()
today = datetime.now(tz=AMSTERDAM).date()
# Calculate the amount of days since the start of the games so we know which word is used today
return (today - settings.start_date).days
def get_game_deadline():
"""Calculate the amount of time left for the current game."""
this_moment = datetime.now(timezone.utc)
midnight = datetime.now(timezone.utc).replace(hour=23, minute=59, second=59, microsecond=0)
this_moment = datetime.now(tz=AMSTERDAM)
midnight = datetime.now(tz=AMSTERDAM).replace(hour=23, minute=59, second=59, microsecond=0)
# Calculate the amount of time left till midnight (and the start of the next game)
return midnight - this_moment
@@ -74,7 +80,7 @@ def is_valid_dictionary_word(word: str) -> bool:
async def index(request: Request):
"""Generate the main HTML page of the game."""
language = 'nl'
return templates.TemplateResponse(request=request, name='index.html', context={'language': language})
return templates.TemplateResponse(request=request, name='index.html', context={'language': language, 'version': VERSION})
@app.get('/api/game')
@@ -106,7 +112,10 @@ def handle_guess(word: Union[str, None] = None):
@app.get('/api/answer/{item_id}')
def read_item(item_id: int, guess: Union[str, None] = None):
"""Get the word for the current game."""
def read_item(item_id: int):
"""Get the word for the game with ID `item_id`."""
current_game_id = get_game_id()
if item_id > current_game_id:
raise HTTPException(status_code=403, detail='No peaking!')
word = words[item_id].strip()
return {'item_id': item_id, 'guess': guess, 'word': word}
return {'item_id': item_id, 'word': word}

View File

@@ -33,12 +33,13 @@ a.title {
padding: 2rem 0 0 0;
}
.guessesheading {
color: #CCC;;
.guessesheading, .copied {
color: #CCC;
}
.guessesheading, .guessesbefore, .guessesafter {
text-align: center;
clear: both;
}
input[type="text"] {

View File

@@ -1,12 +1,10 @@
document.addEventListener('alpine:init', () => {
Alpine.store('alfagok', {
// isLocalStorageAvailable: this.testLocalStorage(),
isLocalStorageAvailable: false,
savedGameKey: 'saveGame',
/* Main alfagok application, state etc */
/** Main alfagok application, state etc */
gameID: Alpine.$persist(0).as('gameID'),
countingDown: '',
nextGameIn: 0,
gameFetchedAt: null,
loading: false,
@@ -26,8 +24,18 @@ document.addEventListener('alpine:init', () => {
resultGuesses: Alpine.$persist('').as('resultGuesses'),
resultTimeTaken: Alpine.$persist('').as('resultTimeTaken'),
resultsCopied: false,
async init() {
/** Initialise the application after loading */
await this.getGameID();
setInterval(() => {
// Update counter to the next game (midnight UTC, fetched from API) every second
this.countDownTimer();
}, 1000);
},
async getGameID() {
/* Get the game number from the backend */
/** Get the game number from the backend */
this.loading = true;
console.log('Loading gameID...');
let response = await fetch('/api/game');
@@ -38,14 +46,19 @@ document.addEventListener('alpine:init', () => {
if (this.gameID !== result.game) {
this.setEmptyGameState();
}
this.nextGameIn = result.deadline;
this.gameFetchedAt = new Date();
if (this.countingDown === '') {
this.countDownTimer();
}
return this.gameID = result.game;
}
},
async doGuess() {
/** Handle the newly entered guess */
this.guessError = null;
/* Normalise on lowercase, and strip whitespace from begin and end, just in case */
/* Normalise on lowercase, and strip whitespace from start and end, just in case */
this.guessValue = this.guessValue.toLowerCase().trim();
if (this.guessValue === '') {
@@ -97,13 +110,14 @@ document.addEventListener('alpine:init', () => {
this.resultGuesses = '🤔 '+ this.nrGuesses + ' gokken';
let winTimeDate = new Date(this.winTime);
let startTimeDate = new Date(this.startTime);
// this.resultTimeTaken = '⏱️ ' + getFormattedTime(this.winTime - this.startTime);
this.resultTimeTaken = '⏱️ ' + getFormattedTime(winTimeDate - startTimeDate);
this.resultTimeTaken = '⏱️ ' + this.getFormattedTime(winTimeDate - startTimeDate);
}
},
setEmptyGameState() {
/** Clean slate for new game */
this.winTime = null;
this.startTime = null;
this.gaveUpTime = null;
this.nrGuesses = 0;
this.guessesBefore = [];
@@ -116,11 +130,9 @@ document.addEventListener('alpine:init', () => {
this.resultGameID = '';
this.resultGuesses = '';
this.resultTimeTaken = '';
this.getGameID();
},
// # Countdown timer
getFormattedTime(milliseconds) {
/** Format the time for 'time played' nicely */
if (!Number.isInteger(milliseconds)) {
return '';
}
@@ -143,19 +155,24 @@ document.addEventListener('alpine:init', () => {
return formattedTime.join(' ') || '0s';
},
// # Countdown timer
addZero(num){
/** Pad with 0 if needed */
if (num <= 9) return '0' + num;
else return num;
},
countDownTimer(){
let nextgame = document.getElementById('nextgame');
/** Update counter to next game (midnight UTC, fetched from API) */
if (this.gameFetchedAt === null) { return; }
let now = new Date();
let midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()+1, 0, 0, 0);
let diff = Math.floor((midnight - now)/1000);
let gameDataFetched = new Date(this.gameFetchedAt);
let nextGameStart = gameDataFetched.setSeconds(gameDataFetched.getSeconds() + this.nextGameIn);
let diff = Math.floor((nextGameStart - now) / 1000);
let hoursRemain = Math.floor(diff / (60*60));
let minutesRemain = Math.floor((diff - hoursRemain*60*60) / 60);
let secondsRemain = Math.floor(diff % 60);
nextgame.innerHTML = '<span class="nextgame">'+addZero(hoursRemain)+':'+addZero(minutesRemain)+':'+addZero(secondsRemain)+' over</span>';
this.countingDown = this.addZero(hoursRemain) + ':' + this.addZero(minutesRemain) + ':' + this.addZero(secondsRemain) + ' over';
}
})
@@ -174,75 +191,14 @@ document.addEventListener('alpine:init', () => {
});
/* Time formatting **/
function getFormattedTime(milliseconds) {
if (!Number.isInteger(milliseconds)) {
return '';
}
let seconds = Math.round((milliseconds) / 1000);
const hours = Math.floor(seconds / 3600);
seconds %= 3600;
const minutes = Math.floor(seconds / 60);
seconds %= 60;
const formattedTime = [];
if (hours) {
formattedTime.push(`${hours}u`);
}
if (minutes) {
formattedTime.push(`${minutes}m`);
}
if (seconds) {
formattedTime.push(`${seconds}s`);
}
return formattedTime.join(' ') || '0s';
}
/* Clipboard stuff **/
let clip = new ClipboardJS('.copy');
clip.on("success", function(e) {
document.getElementById('copyresults').innerHTML = '<p style="font-size:var(--small);opacity:50%">Gekopieerd! Deel je resultaat.</p>';
e.clearSelection();
});
clip.on("error", function() {
document.getElementById('copyresults').innerHTML = '<p style="font-size:var(--small);opacity:50%">Fout. Graag handmatig kopi&euml;ren...</p>';
});
/* Game timer, original from alphaguess.com **/
function go() {
window.timerID = window.setInterval(timer, 0);
}
function timer(){
let nextgame = document.getElementById('nextgame');
let now = new Date();
let midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()+1, 0, 0, 0);
let diff = Math.floor((midnight - now)/1000);
let hoursRemain = Math.floor(diff/(60*60));
let minutesRemain = Math.floor((diff-hoursRemain*60*60)/60);
let secondsRemain = Math.floor(diff%60);
nextgame.innerHTML = '<span class="nextgame">'+addZero(hoursRemain)+':'+addZero(minutesRemain)+':'+addZero(secondsRemain)+' over</span>';
}
function addZero(num){
if(num <=9) return '0'+num;
else return num;
}
go();
/* Get current gameID etc **/
document.addEventListener('alpine:initialized', () => {
// document.addEventListener('alpine:initialized', () => {
/* On AlpineJS completely loaded, do all this */
// Alpine.store('alfagok').getGameID();
// })
document.onvisibilitychange = () => {
if (!document.hidden) {
/* Tab/window is shown again, possibly after a long time, check if game is still current, otherwise re-load */
Alpine.store('alfagok').getGameID();
})
}
};

View File

@@ -5,24 +5,24 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Raad het woord van de dag. Elke gok geeft een hint over waar het woord zich in het alfabet bevindt. Iedereen speelt hetzelfde woord.">
<link rel="stylesheet" href="/static/game.css">
<link id="favicon" rel="icon" type="image/x-icon" href="static/images/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="static/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="static/images/favicon-16x16.png">
<link rel="manifest" href="static/images/site.webmanifest">
<link rel="stylesheet" href="/static/game.css?v={{ version }}">
<link id="favicon" rel="icon" type="image/x-icon" href="/static/images/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/static/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
<link rel="manifest" href="/static/images/site.webmanifest">
<script src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-clipboard@2.x.x/dist/alpine-clipboard.js" defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
<div id="container" x-data="">
<a href="/" x-cloak class="title">alfagok</a> <span class="puzzleno">puzzel #<span x-text="$store.alfagok.gameID"></span><span id="nextgame"></span> | <span x-text="$store.alfagok.countingDown"></span><span x-text="$store.alfagok.nrGuesses"></span> gokken</span>
<a href="/" x-cloak class="title">alfagok</a> <span class="puzzleno">puzzel #<span x-text="$store.alfagok.gameID"></span><span x-text="$store.alfagok.countingDown"></span><span x-text="$store.alfagok.nrGuesses"></span> gokken</span>
<div x-cloak class="instructions" x-show="$store.alfagok.guessesBefore.length === 0 && $store.alfagok.guessesAfter.length === 0">
<p>Raad het woord van de dag. Elke gok geeft een hint over waar het woord zich in het alfabet bevindt.</p>
<p x-show="$store.alfagok.isLocalStorageAvailable"></p>
</div>
<p class="guessesheading" x-cloak x-show="$store.alfagok.guessesBefore.length">Het woord van de dag komt <em>na</em>:</p>
@@ -50,8 +50,8 @@
<p>🔗 <span class="link">alfagok.diginaut.net</span></p>
</div>
</div>
<div id="copyresults"></div>
<button class="copy" data-clipboard-target="#results">
<div class="copied" x-show="$store.alfagok.resultsCopied">Gekopieerd! Deel je resultaat.</div>
<button class="copy" @click="$clipboard($store.alfagok.resultGameID + '\n' + $store.alfagok.resultGuesses + '\n' + $store.alfagok.resultTimeTaken + '\n' + '🔗 alfagok.diginaut.net'); $store.alfagok.resultsCopied = true">
Tik om te kopi&euml;ren en te delen ❤️
</button>
</div>
@@ -65,12 +65,10 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/clipboard@2.0.11/dist/clipboard.min.js"></script>
<script src="/static/game.js"></script>
<script src="/static/game.js?v={{ version }}"></script>
{#
<button x-data @click="$store.darkMode.toggle()">Toggle Dark Mode</button>
<div x-data :class="$store.darkMode.on && 'bg-black'">...</div>
#}
</body>
</html>