chore: add zustand (#4)

main
HumanoidSandvichDispenser 2026-04-11 17:10:13 -07:00 committed by GitHub
parent d57638950f
commit 8af13259ba
6 changed files with 235 additions and 4 deletions

36
package-lock.json generated
View File

@ -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
}
}
}
}
}

View File

@ -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",

View File

@ -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<GameStatsStore>((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,
})
},
}))

View File

@ -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

View File

@ -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<LeaderboardStore, 'rows' | 'loading' | 'fetchError' | 'highlight'>
export const useLeaderboardStore = create<LeaderboardStore>((set) => ({
...INITIAL_STATE,
setRows: (rows) => {
set({ rows })
},
setLoading: (loading) => {
set({ loading })
},
setFetchError: (fetchError) => {
set({ fetchError })
},
setHighlight: (highlight) => {
set({ highlight })
},
reset: () => {
set(INITIAL_STATE)
},
}))

View File

@ -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