Feat: Genre system with per-genre daily puzzles

This commit is contained in:
Hördle Bot
2025-11-22 11:56:16 +01:00
parent dc69fd1498
commit 8c720e287f
9 changed files with 294 additions and 155 deletions

43
app/[genre]/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
import Game from '@/components/Game';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
export const dynamic = 'force-dynamic';
const prisma = new PrismaClient();
interface PageProps {
params: Promise<{ genre: string }>;
}
export default async function GenrePage({ params }: PageProps) {
const { genre } = await params;
const decodedGenre = decodeURIComponent(genre);
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
{genres.map(g => (
<Link
key={g.id}
href={`/${g.name}`}
style={{
fontWeight: g.name === decodedGenre ? 'bold' : 'normal',
textDecoration: g.name === decodedGenre ? 'underline' : 'none',
color: g.name === decodedGenre ? 'black' : '#4b5563'
}}
>
{g.name}
</Link>
))}
</div>
</div>
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
</>
);
}

View File

@@ -1,78 +1,18 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { getTodayISOString } from '@/lib/dateUtils';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
const prisma = new PrismaClient();
export async function GET() {
export async function GET(request: Request) {
try {
const today = getTodayISOString();
const { searchParams } = new URL(request.url);
const genreName = searchParams.get('genre');
let dailyPuzzle = await prisma.dailyPuzzle.findUnique({
where: { date: today },
include: { song: true },
});
const puzzle = await getOrCreateDailyPuzzle(genreName);
console.log(`[Daily Puzzle] Date: ${today}, Found existing: ${!!dailyPuzzle}`);
if (!dailyPuzzle) {
// Get all songs with their usage count
const allSongs = await prisma.song.findMany({
include: {
puzzles: true,
},
});
if (allSongs.length === 0) {
return NextResponse.json({ error: 'No songs available' }, { status: 404 });
}
// Calculate weights: songs never used get weight 1.0,
// songs used once get 0.5, twice 0.33, etc.
const weightedSongs = allSongs.map(song => ({
song,
weight: 1.0 / (song.puzzles.length + 1),
}));
// Calculate total weight
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
// Pick a random song based on weights
let random = Math.random() * totalWeight;
let selectedSong = weightedSongs[0].song;
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
selectedSong = item.song;
break;
}
}
// Create the daily puzzle
dailyPuzzle = await prisma.dailyPuzzle.create({
data: {
date: today,
songId: selectedSong.id,
},
include: { song: true },
});
console.log(`[Daily Puzzle] Created new puzzle for ${today} with song: ${selectedSong.title} (ID: ${selectedSong.id})`);
if (!puzzle) {
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
}
if (!dailyPuzzle) {
return NextResponse.json({ error: 'Failed to create puzzle' }, { status: 500 });
}
return NextResponse.json({
id: dailyPuzzle.id,
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
songId: dailyPuzzle.songId,
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
});
return NextResponse.json(puzzle);
} catch (error) {
console.error('Error fetching daily puzzle:', error);

59
app/api/genres/route.ts Normal file
View File

@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET() {
try {
const genres = await prisma.genre.findMany({
orderBy: { name: 'asc' },
include: {
_count: {
select: { songs: true }
}
}
});
return NextResponse.json(genres);
} catch (error) {
console.error('Error fetching genres:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const { name } = await request.json();
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
}
const genre = await prisma.genre.create({
data: { name: name.trim() },
});
return NextResponse.json(genre);
} catch (error) {
console.error('Error creating genre:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: Request) {
try {
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
await prisma.genre.delete({
where: { id: Number(id) },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting genre:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -11,6 +11,7 @@ export async function GET() {
orderBy: { createdAt: 'desc' },
include: {
puzzles: true,
genres: true,
},
});
@@ -23,6 +24,7 @@ export async function GET() {
createdAt: song.createdAt,
coverImage: song.coverImage,
activations: song.puzzles.length,
genres: song.genres,
}));
return NextResponse.json(songsWithActivations);
@@ -144,6 +146,7 @@ export async function POST(request: Request) {
filename,
coverImage,
},
include: { genres: true }
});
return NextResponse.json({
@@ -158,15 +161,24 @@ export async function POST(request: Request) {
export async function PUT(request: Request) {
try {
const { id, title, artist } = await request.json();
const { id, title, artist, genreIds } = await request.json();
if (!id || !title || !artist) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
}
const data: any = { title, artist };
if (genreIds) {
data.genres = {
set: genreIds.map((gId: number) => ({ id: gId }))
};
}
const updatedSong = await prisma.song.update({
where: { id: Number(id) },
data: { title, artist },
data,
include: { genres: true }
});
return NextResponse.json(updatedSong);

View File

@@ -1,81 +1,29 @@
import Game from '@/components/Game';
import { getTodayISOString } from '@/lib/dateUtils';
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
import Link from 'next/link';
import { PrismaClient } from '@prisma/client';
export const dynamic = 'force-dynamic';
import { PrismaClient } from '@prisma/client';
// PrismaClient is attached to the `global` object in development to prevent
// exhausting your database connection limit.
const globalForPrisma = global as unknown as { prisma: PrismaClient };
const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
async function getDailyPuzzle() {
try {
const today = getTodayISOString();
console.log(`[getDailyPuzzle] Checking puzzle for date: ${today}`);
let dailyPuzzle = await prisma.dailyPuzzle.findUnique({
where: { date: today },
include: { song: true },
});
if (!dailyPuzzle) {
console.log('[getDailyPuzzle] No puzzle found, attempting to create...');
const songsCount = await prisma.song.count();
console.log(`[getDailyPuzzle] Found ${songsCount} songs in DB`);
if (songsCount > 0) {
const skip = Math.floor(Math.random() * songsCount);
const randomSong = await prisma.song.findFirst({ skip });
if (randomSong) {
try {
dailyPuzzle = await prisma.dailyPuzzle.create({
data: { date: today, songId: randomSong.id },
include: { song: true },
});
console.log(`[getDailyPuzzle] Created puzzle for song: ${randomSong.title}`);
} catch (createError) {
// Handle race condition: if another request created it in the meantime
console.log('[getDailyPuzzle] Creation failed, trying to fetch again (likely race condition)');
dailyPuzzle = await prisma.dailyPuzzle.findUnique({
where: { date: today },
include: { song: true },
});
}
}
} else {
console.log('[getDailyPuzzle] No songs available to create puzzle');
}
}
if (!dailyPuzzle) {
console.log('[getDailyPuzzle] Failed to get or create puzzle');
return null;
}
return {
id: dailyPuzzle.id,
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
songId: dailyPuzzle.songId,
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
};
} catch (e) {
console.error('[getDailyPuzzle] Error:', e);
return null;
}
}
const prisma = new PrismaClient();
export default async function Home() {
const dailyPuzzle = await getDailyPuzzle();
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
const genres = await prisma.genre.findMany({ orderBy: { name: 'asc' } });
return (
<Game dailyPuzzle={dailyPuzzle} />
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
{genres.map(g => (
<Link key={g.id} href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
{g.name}
</Link>
))}
</div>
</div>
<Game dailyPuzzle={dailyPuzzle} genre={null} />
</>
);
}

View File

@@ -16,12 +16,13 @@ interface GameProps {
artist: string;
coverImage: string | null;
} | null;
genre?: string | null;
}
const UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
export default function Game({ dailyPuzzle }: GameProps) {
const { gameState, statistics, addGuess } = useGameState();
export default function Game({ dailyPuzzle, genre = null }: GameProps) {
const { gameState, statistics, addGuess } = useGameState(genre);
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('Share Result');
@@ -42,7 +43,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
<h2>No Puzzle Available</h2>
<p>Could not generate a daily puzzle.</p>
<p>Please ensure there are songs in the database.</p>
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
</div>
);
@@ -58,6 +59,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
addGuess(song.title, false);
if (gameState.guesses.length + 1 >= 7) {
setHasLost(true);
setHasWon(false); // Ensure won is false
sendGotifyNotification(7, 'lost', dailyPuzzle.id);
}
}
@@ -72,6 +74,7 @@ export default function Game({ dailyPuzzle }: GameProps) {
setLastAction('SKIP');
addGuess("SKIPPED", false);
setHasLost(true);
setHasWon(false);
sendGotifyNotification(7, 'lost', dailyPuzzle.id);
};
@@ -98,7 +101,8 @@ 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`;
const genreText = genre ? `Genre: ${genre}\n` : '';
const text = `Hördle #${dailyPuzzle.id}\n${genreText}\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\nhttps://hoerdle.elpatron.me`;
// Fallback method for copying to clipboard
const textarea = document.createElement('textarea');

113
lib/dailyPuzzle.ts Normal file
View File

@@ -0,0 +1,113 @@
import { PrismaClient } from '@prisma/client';
import { getTodayISOString } from './dateUtils';
const prisma = new PrismaClient();
export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
try {
const today = getTodayISOString();
let genreId: number | null = null;
if (genreName) {
const genre = await prisma.genre.findUnique({
where: { name: genreName }
});
if (genre) {
genreId = genre.id;
} else {
return null; // Genre not found
}
}
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
where: {
date: today,
genreId: genreId
},
include: { song: true },
});
console.log(`[Daily Puzzle] Date: ${today}, Genre: ${genreName || 'Global'}, Found existing: ${!!dailyPuzzle}`);
if (!dailyPuzzle) {
// Get songs available for this genre
const whereClause = genreId
? { genres: { some: { id: genreId } } }
: {}; // Global puzzle picks from ALL songs
const allSongs = await prisma.song.findMany({
where: whereClause,
include: {
puzzles: {
where: { genreId: genreId }
},
},
});
if (allSongs.length === 0) {
console.log(`[Daily Puzzle] No songs available for genre: ${genreName || 'Global'}`);
return null;
}
// Calculate weights
const weightedSongs = allSongs.map(song => ({
song,
weight: 1.0 / (song.puzzles.length + 1),
}));
// Calculate total weight
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
// Pick a random song based on weights
let random = Math.random() * totalWeight;
let selectedSong = weightedSongs[0].song;
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
selectedSong = item.song;
break;
}
}
// Create the daily puzzle
try {
dailyPuzzle = await prisma.dailyPuzzle.create({
data: {
date: today,
songId: selectedSong.id,
genreId: genreId
},
include: { song: true },
});
console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genreName || 'Global'}) with song: ${selectedSong.title}`);
} catch (e) {
// Handle race condition
console.log('[Daily Puzzle] Creation failed, trying to fetch again (likely race condition)');
dailyPuzzle = await prisma.dailyPuzzle.findFirst({
where: {
date: today,
genreId: genreId
},
include: { song: true },
});
}
}
if (!dailyPuzzle) return null;
return {
id: dailyPuzzle.id,
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
songId: dailyPuzzle.songId,
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
genre: genreName
};
} catch (error) {
console.error('Error in getOrCreateDailyPuzzle:', error);
return null;
}
}

View File

@@ -25,13 +25,20 @@ export interface Statistics {
const STORAGE_KEY = 'hoerdle_game_state';
const STATS_KEY = 'hoerdle_statistics';
export function useGameState() {
export function useGameState(genre: string | null = null) {
const [gameState, setGameState] = useState<GameState | null>(null);
const [statistics, setStatistics] = useState<Statistics | null>(null);
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
const STATS_KEY_PREFIX = 'hoerdle_statistics';
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
useEffect(() => {
// Load game state
const stored = localStorage.getItem(STORAGE_KEY);
const storageKey = getStorageKey();
const stored = localStorage.getItem(storageKey);
const today = getTodayISOString();
if (stored) {
@@ -48,7 +55,7 @@ export function useGameState() {
lastPlayed: Date.now(),
};
setGameState(newState);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
localStorage.setItem(storageKey, JSON.stringify(newState));
}
} else {
// No state
@@ -60,11 +67,12 @@ export function useGameState() {
lastPlayed: Date.now(),
};
setGameState(newState);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
localStorage.setItem(storageKey, JSON.stringify(newState));
}
// Load statistics
const storedStats = localStorage.getItem(STATS_KEY);
const statsKey = getStatsKey();
const storedStats = localStorage.getItem(statsKey);
if (storedStats) {
const parsedStats = JSON.parse(storedStats);
// Migration for existing stats without solvedIn7
@@ -84,13 +92,13 @@ export function useGameState() {
failed: 0,
};
setStatistics(newStats);
localStorage.setItem(STATS_KEY, JSON.stringify(newStats));
localStorage.setItem(statsKey, JSON.stringify(newStats));
}
}, []);
}, [genre]); // Re-run when genre changes
const saveState = (newState: GameState) => {
setGameState(newState);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
};
const updateStatistics = (attempts: number, solved: boolean) => {
@@ -113,7 +121,7 @@ export function useGameState() {
}
setStatistics(newStats);
localStorage.setItem(STATS_KEY, JSON.stringify(newStats));
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
};
const addGuess = (guess: string, correct: boolean) => {

View File

@@ -18,11 +18,23 @@ model Song {
coverImage String? // Filename in public/uploads/covers
createdAt DateTime @default(now())
puzzles DailyPuzzle[]
genres Genre[]
}
model Genre {
id Int @id @default(autoincrement())
name String @unique
songs Song[]
dailyPuzzles DailyPuzzle[]
}
model DailyPuzzle {
id Int @id @default(autoincrement())
date String @unique // Format: YYYY-MM-DD
date String // Format: YYYY-MM-DD
songId Int
song Song @relation(fields: [songId], references: [id])
genreId Int?
genre Genre? @relation(fields: [genreId], references: [id])
@@unique([date, genreId]) // Unique puzzle per date per genre (null genreId = global puzzle)
}