chore: add zustand (#4)
parent
d57638950f
commit
8af13259ba
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
},
|
||||
}))
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue