From ea26649558b44033a186b966572a00e61ad36fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Fri, 21 Nov 2025 13:20:45 +0100 Subject: [PATCH] feat: Add statistics tracking and fix clipboard API - Add personal statistics with badges for each attempt count - Track solved puzzles per attempt (1-6) and failed attempts - Display statistics after game completion - Fix clipboard API issue with fallback method - Add footer with attribution --- app/globals.css | 93 ++++++++++++++++++++++++++++++++++++--- app/layout.tsx | 9 ++++ components/Game.tsx | 24 ++++++++-- components/Statistics.tsx | 56 +++++++++++++++++++++++ lib/gameState.ts | 92 +++++++++++++++++++++++++++++++++----- 5 files changed, 252 insertions(+), 22 deletions(-) create mode 100644 components/Statistics.tsx diff --git a/app/globals.css b/app/globals.css index 42e3d8a..402347c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -6,12 +6,9 @@ body { color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, + background: linear-gradient(to bottom, transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); + rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb)); font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; margin: 0; @@ -63,7 +60,7 @@ body { background: #f3f4f6; padding: 1rem; border-radius: 0.5rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .player-controls { @@ -128,7 +125,8 @@ body { } .guess-text { - color: #ef4444; /* Red for wrong */ + color: #ef4444; + /* Red for wrong */ } .guess-text.skipped { @@ -272,3 +270,84 @@ body { .btn-primary:hover { background: #333; } + +/* Footer */ +.app-footer { + margin-top: auto; + padding: 2rem 1rem 1rem; + text-align: center; + font-size: 0.875rem; + color: #666; + border-top: 1px solid #e5e7eb; + width: 100%; +} + +.app-footer p { + margin: 0; +} + +.app-footer a { + color: #000; + text-decoration: none; + font-weight: 500; +} + +.app-footer a:hover { + text-decoration: underline; +} + +/* Statistics */ +.statistics-container { + margin: 1.5rem 0; + padding: 1rem; + background: rgba(255, 255, 255, 0.5); + border-radius: 0.5rem; +} + +.statistics-title { + font-size: 1.125rem; + font-weight: bold; + margin: 0 0 0.5rem 0; + text-align: center; +} + +.statistics-total { + font-size: 0.875rem; + text-align: center; + margin: 0 0 1rem 0; + color: #666; +} + +.statistics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(80px, 1fr)); + gap: 0.75rem; +} + +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 0.5rem; + background: rgba(255, 255, 255, 0.8); + border-radius: 0.375rem; + border: 1px solid #e5e7eb; +} + +.stat-badge { + font-size: 1.5rem; + margin-bottom: 0.25rem; +} + +.stat-label { + font-size: 0.75rem; + color: #666; + margin-bottom: 0.25rem; + text-align: center; +} + +.stat-count { + font-size: 1.25rem; + font-weight: bold; + color: #000; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 42fc323..7297828 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -26,6 +26,15 @@ export default function RootLayout({ {children} + ); diff --git a/components/Game.tsx b/components/Game.tsx index c13cf29..7450bae 100644 --- a/components/Game.tsx +++ b/components/Game.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import AudioPlayer from './AudioPlayer'; import GuessInput from './GuessInput'; +import Statistics from './Statistics'; import { useGameState } from '../lib/gameState'; interface GameProps { @@ -16,7 +17,7 @@ interface GameProps { const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30]; export default function Game({ dailyPuzzle }: GameProps) { - const { gameState, addGuess } = useGameState(); + const { gameState, statistics, addGuess } = useGameState(); const [hasWon, setHasWon] = useState(false); const [hasLost, setHasLost] = useState(false); const [shareText, setShareText] = useState('Share Result'); @@ -68,10 +69,25 @@ export default function Game({ dailyPuzzle }: GameProps) { const speaker = hasWon ? '🔉' : '🔇'; const text = `Hördle #${dailyPuzzle.id}\n\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`; - navigator.clipboard.writeText(text).then(() => { + // Fallback method for copying to clipboard + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + try { + document.execCommand('copy'); setShareText('Copied!'); setTimeout(() => setShareText('Share Result'), 2000); - }); + } catch (err) { + console.error('Failed to copy:', err); + setShareText('Copy failed'); + setTimeout(() => setShareText('Share Result'), 2000); + } finally { + document.body.removeChild(textarea); + } }; return ( @@ -123,6 +139,7 @@ export default function Game({ dailyPuzzle }: GameProps) {

You won!

Come back tomorrow for a new song.

+ {statistics && } @@ -133,6 +150,7 @@ export default function Game({ dailyPuzzle }: GameProps) {

Game Over

The song was hidden.

+ {statistics && } diff --git a/components/Statistics.tsx b/components/Statistics.tsx new file mode 100644 index 0000000..8ad2b2e --- /dev/null +++ b/components/Statistics.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Statistics as StatsType } from '../lib/gameState'; + +interface StatisticsProps { + statistics: StatsType; +} + +const BADGES = { + 1: '🏆', // Gold trophy + 2: '🥈', // Silver medal + 3: '🥉', // Bronze medal + 4: '⭐', // Star + 5: '✨', // Sparkles + 6: '💫', // Dizzy + failed: '❌', // Cross mark +}; + +export default function Statistics({ statistics }: StatisticsProps) { + const total = + statistics.solvedIn1 + + statistics.solvedIn2 + + statistics.solvedIn3 + + statistics.solvedIn4 + + statistics.solvedIn5 + + statistics.solvedIn6 + + statistics.failed; + + const stats = [ + { attempts: 1, count: statistics.solvedIn1, badge: BADGES[1] }, + { attempts: 2, count: statistics.solvedIn2, badge: BADGES[2] }, + { attempts: 3, count: statistics.solvedIn3, badge: BADGES[3] }, + { attempts: 4, count: statistics.solvedIn4, badge: BADGES[4] }, + { attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] }, + { attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] }, + { attempts: 'Failed', count: statistics.failed, badge: BADGES.failed }, + ]; + + return ( +
+

Your Statistics

+

Total puzzles: {total}

+
+ {stats.map((stat, index) => ( +
+
{stat.badge}
+
+ {typeof stat.attempts === 'number' ? `${stat.attempts} try` : stat.attempts} +
+
{stat.count}
+
+ ))} +
+
+ ); +} diff --git a/lib/gameState.ts b/lib/gameState.ts index 4354ca5..b705262 100644 --- a/lib/gameState.ts +++ b/lib/gameState.ts @@ -10,12 +10,25 @@ export interface GameState { lastPlayed: number; // Timestamp } +export interface Statistics { + solvedIn1: number; + solvedIn2: number; + solvedIn3: number; + solvedIn4: number; + solvedIn5: number; + solvedIn6: number; + failed: number; +} + const STORAGE_KEY = 'hoerdle_game_state'; +const STATS_KEY = 'hoerdle_statistics'; export function useGameState() { const [gameState, setGameState] = useState(null); + const [statistics, setStatistics] = useState(null); useEffect(() => { + // Load game state const stored = localStorage.getItem(STORAGE_KEY); const today = new Date().toISOString().split('T')[0]; @@ -23,20 +36,48 @@ export function useGameState() { const parsed: GameState = JSON.parse(stored); if (parsed.date === today) { setGameState(parsed); - return; + } else { + // New day + const newState: GameState = { + date: today, + guesses: [], + isSolved: false, + isFailed: false, + lastPlayed: Date.now(), + }; + setGameState(newState); + localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); } + } else { + // No state + const newState: GameState = { + date: today, + guesses: [], + isSolved: false, + isFailed: false, + lastPlayed: Date.now(), + }; + setGameState(newState); + localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); } - // New day or no state - const newState: GameState = { - date: today, - guesses: [], - isSolved: false, - isFailed: false, - lastPlayed: Date.now(), - }; - setGameState(newState); - localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); + // Load statistics + const storedStats = localStorage.getItem(STATS_KEY); + if (storedStats) { + setStatistics(JSON.parse(storedStats)); + } else { + const newStats: Statistics = { + solvedIn1: 0, + solvedIn2: 0, + solvedIn3: 0, + solvedIn4: 0, + solvedIn5: 0, + solvedIn6: 0, + failed: 0, + }; + setStatistics(newStats); + localStorage.setItem(STATS_KEY, JSON.stringify(newStats)); + } }, []); const saveState = (newState: GameState) => { @@ -44,6 +85,28 @@ export function useGameState() { localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); }; + const updateStatistics = (attempts: number, solved: boolean) => { + if (!statistics) return; + + const newStats = { ...statistics }; + + if (solved) { + switch (attempts) { + case 1: newStats.solvedIn1++; break; + case 2: newStats.solvedIn2++; break; + case 3: newStats.solvedIn3++; break; + case 4: newStats.solvedIn4++; break; + case 5: newStats.solvedIn5++; break; + case 6: newStats.solvedIn6++; break; + } + } else { + newStats.failed++; + } + + setStatistics(newStats); + localStorage.setItem(STATS_KEY, JSON.stringify(newStats)); + }; + const addGuess = (guess: string, correct: boolean) => { if (!gameState) return; @@ -60,7 +123,12 @@ export function useGameState() { }; saveState(newState); + + // Update statistics when game ends + if (isSolved || isFailed) { + updateStatistics(newGuesses.length, isSolved); + } }; - return { gameState, addGuess }; + return { gameState, statistics, addGuess }; }