Skip to content

(feat) Persistent, named custom vocabulary decks#7211

Open
a-aznar wants to merge 2 commits intolingdojo:mainfrom
a-aznar:main
Open

(feat) Persistent, named custom vocabulary decks#7211
a-aznar wants to merge 2 commits intolingdojo:mainfrom
a-aznar:main

Conversation

@a-aznar
Copy link
Contributor

@a-aznar a-aznar commented Mar 4, 2026

📝 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:

    • customDecks
    • createCustomDeck
    • updateCustomDeckName
    • addVocabToCustomDeck
    • removeVocabFromCustomDeck
    • deleteCustomDeck
    • setSelectedVocabObjs (loads a deck into the active selection)
      (features/Vocabulary/store/useVocabStore.ts)
  • Deduplication + persistence:

    • Deduplicates vocab entries by word when creating or adding to decks.
    • Persists only customDecks to local storage using zustand persist partialization under the key vocabulary-storage.
  • UI: CustomDeckManager:

    • New client component providing a searchable vocab list across all JLPT levels.
    • Supports creating decks by selecting words, editing deck contents/names, deleting decks, and loading a deck for play.
      (features/Vocabulary/components/CustomDeckManager.tsx)
  • Integration:

    • Manager is displayed in the Vocabulary UI below the existing JLPT level set cards.
      (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:

  1. Go to Vocabulary

  2. Select custom decks tab

  3. Create deck
    3.1 Set a name
    3.2 Select vocabs
    3.3 (Optional) Select/Deselect vocab

  4. Save deck

  5. Select Deck and practice Blitz/Classic/Gauntlet

  6. Refresh website

  7. Go to Vocabulary

  8. Select custom decks tab

  9. Custom created deck has persisted

  10. Refresh website

  11. Go to Vocabulary

  12. Select custom decks tab

  13. Delete custom deck

  14. Confirmation pop up shows

  15. Select delete

  16. 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.tsx

Manual Test Checklist:

  • Tested in Kana dojo
  • Tested in Kanji dojo
  • Tested in Vocabulary dojo
  • [x ] Tested all 4 game modes (Pick, Reverse-Pick, Input, Reverse-Input)
  • Verified custom decks persist after refresh (localStorage vocabulary-storage)
  • Verified deck creation and word-level dedupe
  • Verified editing deck name and contents (add/remove)
  • Verified deleting a deck
  • Verified loading a deck populates the active selection via setSelectedVocabObjs

📸 Screenshots/Videos (if applicable)

image image image image image

✅ Pre-Submission Checklist

  • My code follows the project's code style and uses cn() utility where needed.
  • I have run npm run check locally and there are no TypeScript/ESLint errors.
  • My commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/) format.
  • I have updated the documentation (if applicable).
  • This PR is against the main branch.

Note: npm run lint fails 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

  • Commit hooks (lint-staged: eslint --fix, prettier --write, tsc --noEmit) ran and passed for staged changes.
  • Persistence uses zustand persist partialization to store only customDecks under vocabulary-storage, keeping local storage minimal and stable.

a-aznar added 2 commits March 5, 2026 00:32
* 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
@tentoumushii
Copy link
Collaborator

🎉 Thanks for your Pull Request, @a-aznar!

We appreciate your contribution to KanaDojo!

Pre-merge checklist:

  • You starred our repo ⭐
  • Code follows project style guidelines
  • Changes have been tested locally
  • PR title is descriptive
  • If this closes an issue, it's linked with Closes #<number>

A maintainer will review your PR shortly. In the meantime, make sure all CI checks pass. You can run npm run check locally to match CI.

ありがとうございます! 🙏

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 customDecks in the vocab Zustand store (with deck CRUD + selection APIs).
  • Add a new CustomDeckManager UI for creating/editing/deleting/loading decks.
  • Integrate a new “Custom” vocabulary collection option and update selection label handling for non-Set N labels.

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.

Comment on lines +33 to +55
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),
);
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +175 to +180
{
name: 'vocabulary-storage',
partialize: state => ({
customDecks: state.customDecks,
}),
},
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +134
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,
),
})),
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
),
};
}),

Copilot uses AI. Check for mistakes.
Comment on lines +166 to +170
setDraftSelection(prev =>
selectedDraftWords.has(vocabObj.word)
? prev.filter(currentVocab => currentVocab.word !== vocabObj.word)
: dedupeWords([...prev, vocabObj]),
);
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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]);
});

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants