diff --git a/package-lock.json b/package-lock.json index c10b3a8..f000508 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@supabase/supabase-js": "^2.103.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.14.0" + "react-router-dom": "^7.14.0", + "zustand": "^5.0.12" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -963,7 +964,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -1555,7 +1556,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -3140,6 +3141,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 25cbd8e..e4ac8fd 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "@supabase/supabase-js": "^2.103.0", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.14.0" + "react-router-dom": "^7.14.0", + "zustand": "^5.0.12" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/src/stores/game-stats/store.ts b/src/stores/game-stats/store.ts new file mode 100644 index 0000000..111c230 --- /dev/null +++ b/src/stores/game-stats/store.ts @@ -0,0 +1,114 @@ +import { create } from 'zustand' +import { letterGradeFromAccuracy } from '../../lib/judgmentGrade' +import type { GameStatsStore } from './types' + +function sanitizeCount(value: number): number { + return Math.max(0, Math.floor(value)) +} + +function sanitizeScore(value: number): number { + return Math.max(0, Math.floor(value)) +} + +function computeGrade(perfectHits: number, goodHits: number, misses: number): number { + const totalHits = perfectHits + goodHits + misses + if (totalHits === 0) return 0 + return (perfectHits * 100 + goodHits * 70) / totalHits +} + +function withComputedValues(perfectHits: number, goodHits: number, misses: number) { + const computedGrade = computeGrade(perfectHits, goodHits, misses) + const computedGradeLetter = letterGradeFromAccuracy(computedGrade) + return { computedGrade, computedGradeLetter } +} + +const INITIAL_COUNTS = { + perfectHits: 0, + goodHits: 0, + misses: 0, +} + +const INITIAL_GRADE = withComputedValues( + INITIAL_COUNTS.perfectHits, + INITIAL_COUNTS.goodHits, + INITIAL_COUNTS.misses, +) + +export const useGameStatsStore = create((set) => ({ + currentSong: null, + score: 0, + ...INITIAL_COUNTS, + ...INITIAL_GRADE, + + setCurrentSong: (song) => { + set({ currentSong: song }) + }, + + setScore: (score) => { + set({ score: sanitizeScore(score) }) + }, + + addScore: (amount) => { + set((state) => ({ score: sanitizeScore(state.score + amount) })) + }, + + addPerfectHit: () => { + set((state) => { + const perfectHits = state.perfectHits + 1 + return { + perfectHits, + ...withComputedValues(perfectHits, state.goodHits, state.misses), + } + }) + }, + + addGoodHit: () => { + set((state) => { + const goodHits = state.goodHits + 1 + return { + goodHits, + ...withComputedValues(state.perfectHits, goodHits, state.misses), + } + }) + }, + + addMiss: () => { + set((state) => { + const misses = state.misses + 1 + return { + misses, + ...withComputedValues(state.perfectHits, state.goodHits, misses), + } + }) + }, + + setJudgments: ({ perfectHits, goodHits, misses }) => { + const safePerfect = sanitizeCount(perfectHits) + const safeGood = sanitizeCount(goodHits) + const safeMisses = sanitizeCount(misses) + set({ + perfectHits: safePerfect, + goodHits: safeGood, + misses: safeMisses, + ...withComputedValues(safePerfect, safeGood, safeMisses), + }) + }, + + resetStats: () => { + set((state) => ({ + score: 0, + ...INITIAL_COUNTS, + ...INITIAL_GRADE, + currentSong: state.currentSong, + })) + }, + + resetAll: () => { + set({ + currentSong: null, + score: 0, + ...INITIAL_COUNTS, + ...INITIAL_GRADE, + }) + }, +})) diff --git a/src/stores/game-stats/types.ts b/src/stores/game-stats/types.ts new file mode 100644 index 0000000..4389d3d --- /dev/null +++ b/src/stores/game-stats/types.ts @@ -0,0 +1,26 @@ +import type { Song } from '../../features/song-select/types' +import type { LetterGrade } from '../../lib/judgmentGrade' + +export type GameStatsState = { + currentSong: Song | null + perfectHits: number + goodHits: number + misses: number + score: number + computedGrade: number + computedGradeLetter: LetterGrade +} + +export type GameStatsActions = { + setCurrentSong: (song: Song | null) => void + setScore: (score: number) => void + addScore: (amount: number) => void + addPerfectHit: () => void + addGoodHit: () => void + addMiss: () => void + setJudgments: (stats: { perfectHits: number; goodHits: number; misses: number }) => void + resetStats: () => void + resetAll: () => void +} + +export type GameStatsStore = GameStatsState & GameStatsActions diff --git a/src/stores/leaderboard/store.ts b/src/stores/leaderboard/store.ts new file mode 100644 index 0000000..1767bcb --- /dev/null +++ b/src/stores/leaderboard/store.ts @@ -0,0 +1,33 @@ +import { create } from 'zustand' +import type { LeaderboardStore } from './types' + +const INITIAL_STATE = { + rows: [], + loading: false, + fetchError: null, + highlight: null, +} satisfies Pick + +export const useLeaderboardStore = create((set) => ({ + ...INITIAL_STATE, + + setRows: (rows) => { + set({ rows }) + }, + + setLoading: (loading) => { + set({ loading }) + }, + + setFetchError: (fetchError) => { + set({ fetchError }) + }, + + setHighlight: (highlight) => { + set({ highlight }) + }, + + reset: () => { + set(INITIAL_STATE) + }, +})) diff --git a/src/stores/leaderboard/types.ts b/src/stores/leaderboard/types.ts new file mode 100644 index 0000000..b6c99cc --- /dev/null +++ b/src/stores/leaderboard/types.ts @@ -0,0 +1,27 @@ +export type LeaderboardEntry = { + username: string + highscore: number + grade: string +} + +export type LeaderboardHighlight = { + username: string + score: number +} + +export type LeaderboardState = { + rows: LeaderboardEntry[] + loading: boolean + fetchError: string | null + highlight: LeaderboardHighlight | null +} + +export type LeaderboardActions = { + setRows: (rows: LeaderboardEntry[]) => void + setLoading: (loading: boolean) => void + setFetchError: (error: string | null) => void + setHighlight: (highlight: LeaderboardHighlight | null) => void + reset: () => void +} + +export type LeaderboardStore = LeaderboardState & LeaderboardActions