(feat) Persistent, named custom vocabulary decks#7211
(feat) Persistent, named custom vocabulary decks#7211a-aznar wants to merge 2 commits intolingdojo:mainfrom
Conversation
* feat(vocabulary): add persistent custom vocab decks * feat(vocabulary): move custom decks into unit tab flow * fix(vocabulary): show play action bar for custom deck selections * feat(vocabulary): support hiragana and romaji deck search * feat(vocabulary): move deck create/edit into modal * fix(vocabulary): align deck actions and confirm deletion
🎉 Thanks for your Pull Request, @a-aznar!We appreciate your contribution to KanaDojo! Pre-merge checklist:
A maintainer will review your PR shortly. In the meantime, make sure all CI checks pass. You can run ありがとうございます! 🙏 |
There was a problem hiding this comment.
Pull request overview
Adds first-class support for user-created, named vocabulary decks that persist across sessions and can be selected/played similarly to existing JLPT unit selections.
Changes:
- Persist
customDecksin the vocab Zustand store (with deck CRUD + selection APIs). - Add a new
CustomDeckManagerUI for creating/editing/deleting/loading decks. - Integrate a new “Custom” vocabulary collection option and update selection label handling for non-
Set Nlabels.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| shared/lib/selectionFormatting.ts | Avoids numeric “Set” parsing for non-numbered labels (e.g., deck labels). |
| shared/components/Menu/UnitSelector.tsx | Adds a custom collection option for vocabulary selection UI. |
| shared/components/Menu/TrainingActionBar.tsx | Allows Vocabulary “Start” readiness based on selected deck labels or selected vocab items. |
| features/Vocabulary/store/useVocabStore.ts | Adds persistent customDecks plus deck CRUD APIs and setSelectedVocabObjs. |
| features/Vocabulary/components/index.tsx | Routes vocabulary view to CustomDeckManager when custom is selected. |
| features/Vocabulary/components/CustomDeckManager.tsx | New UI for managing and loading custom decks with search + deck editor modal. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const includesQuery = (vocabObj: IVocabObj, query: string) => { | ||
| if (!query) return true; | ||
|
|
||
| const normalizedQuery = normalize(query); | ||
| const queryKana = normalize(toKana(query)); | ||
| const queryRomaji = normalize(toRomaji(query)); | ||
|
|
||
| const searchTerms = [ | ||
| normalize(vocabObj.word), | ||
| normalize(vocabObj.reading), | ||
| normalize(toKana(vocabObj.word)), | ||
| normalize(toKana(vocabObj.reading)), | ||
| normalize(toRomaji(vocabObj.word)), | ||
| normalize(toRomaji(vocabObj.reading)), | ||
| ...vocabObj.meanings.map(meaning => normalize(meaning)), | ||
| ]; | ||
|
|
||
| return searchTerms.some( | ||
| term => | ||
| term.includes(normalizedQuery) || | ||
| term.includes(queryKana) || | ||
| term.includes(queryRomaji), | ||
| ); |
There was a problem hiding this comment.
includesQuery recomputes normalizedQuery/queryKana/queryRomaji (including toKana/toRomaji) every time it's called, and it's called once per vocab entry during filtering. With thousands of vocab items this becomes unnecessarily expensive on each keystroke. Precompute the normalized query variants once (outside the per-item predicate) and reuse them, and consider precomputing normalized searchable fields for each vocab entry when allVocabObjs is built.
| { | ||
| name: 'vocabulary-storage', | ||
| partialize: state => ({ | ||
| customDecks: state.customDecks, | ||
| }), | ||
| }, |
There was a problem hiding this comment.
The persisted vocab store doesn't specify an SSR-safe storage. In this codebase, other persisted Zustand stores guard access to Web Storage with typeof window !== 'undefined' ? createJSONStorage(() => sessionStorage) : undefined to avoid crashes in non-browser contexts. Consider following the same pattern here (using localStorage) so importing the store in SSR/tests doesn't throw.
| set(state => ({ | ||
| customDecks: [ | ||
| ...state.customDecks, | ||
| { | ||
| id: | ||
| typeof crypto !== 'undefined' && 'randomUUID' in crypto | ||
| ? crypto.randomUUID() | ||
| : `${Date.now()}-${Math.random().toString(16).slice(2)}`, | ||
| name: name.trim(), | ||
| vocabObjs: uniqByWord(vocabObjs), | ||
| }, | ||
| ], | ||
| })), | ||
| updateCustomDeckName: (deckId, name) => | ||
| set(state => ({ | ||
| customDecks: state.customDecks.map(deck => | ||
| deck.id === deckId ? { ...deck, name: name.trim() } : deck, | ||
| ), | ||
| })), |
There was a problem hiding this comment.
createCustomDeck / updateCustomDeckName always apply name.trim() but don't prevent empty names (e.g., whitespace-only input) from being stored, which can lead to blank deck labels and confusing selection status text. Since these are new store APIs, consider enforcing a non-empty name (no-op or throw) at the store level (and optionally clamp to DECK_NAME_CHAR_LIMIT) rather than relying only on UI validation.
| set(state => ({ | |
| customDecks: [ | |
| ...state.customDecks, | |
| { | |
| id: | |
| typeof crypto !== 'undefined' && 'randomUUID' in crypto | |
| ? crypto.randomUUID() | |
| : `${Date.now()}-${Math.random().toString(16).slice(2)}`, | |
| name: name.trim(), | |
| vocabObjs: uniqByWord(vocabObjs), | |
| }, | |
| ], | |
| })), | |
| updateCustomDeckName: (deckId, name) => | |
| set(state => ({ | |
| customDecks: state.customDecks.map(deck => | |
| deck.id === deckId ? { ...deck, name: name.trim() } : deck, | |
| ), | |
| })), | |
| set(state => { | |
| const trimmedName = name.trim(); | |
| if (!trimmedName) { | |
| return state; | |
| } | |
| return { | |
| customDecks: [ | |
| ...state.customDecks, | |
| { | |
| id: | |
| typeof crypto !== 'undefined' && 'randomUUID' in crypto | |
| ? crypto.randomUUID() | |
| : `${Date.now()}-${Math.random().toString(16).slice(2)}`, | |
| name: trimmedName, | |
| vocabObjs: uniqByWord(vocabObjs), | |
| }, | |
| ], | |
| }; | |
| }), | |
| updateCustomDeckName: (deckId, name) => | |
| set(state => { | |
| const trimmedName = name.trim(); | |
| if (!trimmedName) { | |
| return state; | |
| } | |
| return { | |
| customDecks: state.customDecks.map(deck => | |
| deck.id === deckId ? { ...deck, name: trimmedName } : deck, | |
| ), | |
| }; | |
| }), |
| setDraftSelection(prev => | ||
| selectedDraftWords.has(vocabObj.word) | ||
| ? prev.filter(currentVocab => currentVocab.word !== vocabObj.word) | ||
| : dedupeWords([...prev, vocabObj]), | ||
| ); |
There was a problem hiding this comment.
toggleDraftVocab uses selectedDraftWords (derived from draftSelection in the render) inside the functional setDraftSelection(prev => ...) update. This can go stale under React's batched updates, causing toggles to add/remove the wrong items. Compute membership from prev inside the updater (e.g., build a Set from prev or use prev.some(...)) rather than using selectedDraftWords from the outer closure.
| setDraftSelection(prev => | |
| selectedDraftWords.has(vocabObj.word) | |
| ? prev.filter(currentVocab => currentVocab.word !== vocabObj.word) | |
| : dedupeWords([...prev, vocabObj]), | |
| ); | |
| setDraftSelection(prev => { | |
| const hasWord = prev.some( | |
| currentVocab => currentVocab.word === vocabObj.word, | |
| ); | |
| return hasWord | |
| ? prev.filter(currentVocab => currentVocab.word !== vocabObj.word) | |
| : dedupeWords([...prev, vocabObj]); | |
| }); |
📝 Description
This PR adds persistent, named custom vocabulary decks that users can create, edit, delete, and play like existing JLPT selections, with decks persisted across sessions.
Key changes:
Vocabulary store (Zustand): added persistent deck storage and APIs:
customDeckscreateCustomDeckupdateCustomDeckNameaddVocabToCustomDeckremoveVocabFromCustomDeckdeleteCustomDecksetSelectedVocabObjs(loads a deck into the active selection)(
features/Vocabulary/store/useVocabStore.ts)Deduplication + persistence:
customDecksto local storage using zustand persist partialization under the keyvocabulary-storage.UI: CustomDeckManager:
(
features/Vocabulary/components/CustomDeckManager.tsx)Integration:
(
features/Vocabulary/components/index.tsx)🔗 Related Issue
N/A
🎯 Type of Change
fix: Bug fix (non-breaking change which fixes an issue)feat: New feature (non-breaking change which adds functionality)docs: Documentation update (e.g., this file, README)content: Content update (e.g., new kanji, vocab, or fonts in/static/)style: UI/Theme changes (e.g., Tailwind, CSS, new themes)refactor: Code refactor (no functional changes)test: Test update (adding missing tests or correcting existing tests)chore: Build, CI/CD, or dependency updates🧪 How Has This Been Tested?
Test Steps:
Go to Vocabulary
Select custom decks tab
Create deck
3.1 Set a name
3.2 Select vocabs
3.3 (Optional) Select/Deselect vocab
Save deck
Select Deck and practice Blitz/Classic/Gauntlet
Refresh website
Go to Vocabulary
Select custom decks tab
Custom created deck has persisted
Refresh website
Go to Vocabulary
Select custom decks tab
Delete custom deck
Confirmation pop up shows
Select delete
Popup closes and deck is now deleted
Also Ran eslint on modified files:
npx eslint features/Vocabulary/store/useVocabStore.ts features/Vocabulary/components/CustomDeckManager.tsx features/Vocabulary/components/index.tsxManual Test Checklist:
vocabulary-storage)setSelectedVocabObjs📸 Screenshots/Videos (if applicable)
✅ Pre-Submission Checklist
cn()utility where needed.npm run checklocally and there are no TypeScript/ESLint errors.mainbranch.Note:
npm run lintfails repo-wide due to pre-existing unrelated lint errors, not introduced by this PR.Helpful links: [Contributing](../blob/main/CONTRIBUTING.md) · [Troubleshooting](../blob/main/docs/TROUBLESHOOTING.md)
📦 Additional Context
lint-staged:eslint --fix,prettier --write,tsc --noEmit) ran and passed for staged changes.customDecksundervocabulary-storage, keeping local storage minimal and stable.