-
Notifications
You must be signed in to change notification settings - Fork 1
Feature/mission word page #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Test Results256 tests 256 ✅ 45s ⏱️ Results for commit 1d21a84. |
🧪 Test Results & Coverage Report✅ All Tests Passed! (256/256)🎉 Great work! All your tests are passing. 📋 Test Suites Summary
🔍 Detailed Test Results✅ jest tests (256/256 passed, 22787ms)✅ KoreanMission 초기 렌더링이 정상적으로 되는지 확인 📋 View detailed workflow results 📊 Code Coverage Report
🔴 Low Coverage: 18.6%Your code coverage is below recommended levels. Please add more tests. 📂 Coverage by File (176 files tested)Click to expand file-by-file coverage
📈 RecommendationsConsider improving test coverage for:
🤖 Automated report | ⏱️ Generated: 2025. 12. 29. 오전 10:12:18 KST | 🔄 Workflow: Run Tests on main PR |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements a mission word page feature that introduces special document pages for displaying Korean mission characters. The feature adds the ability to view mission words organized by specific Korean characters (가, 나, 다, etc.) with three different page types: first-letter mission words, last-letter mission words, and 3-character mission words.
Key Changes
- Added database support for mission word marking with new
mission_markfield in the words table - Implemented mission character filtering and highlighting in word lists
- Created special index pages (IDs 208, 223, 238) that display grids linking to individual character pages
- Added logic to fetch and display mission words based on character masks using new database functions
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| supabase/.temp/cli-latest | Updated Supabase CLI version from v2.62.10 to v2.67.1 |
| app/types/database.types.ts | Added mission_mark field to words table schema and new RPC functions for mission word queries |
| app/lib/lib.ts | Added misssionCharMask utility function to generate bit masks for mission characters |
| app/lib/supabase/SupabaseClientManager.ts | Implemented mission word filtering logic for special document IDs (209-252) and added docsLastUpdate method |
| app/lib/supabase/ISupabaseClientManager.ts | Updated interface types to include optional mission_mark field and new docsLastUpdate method |
| app/words-docs/[id]/WordsTableBody.tsx | Added isSp parameter for special mission character highlighting |
| app/words-docs/[id]/Table.tsx | Implemented character highlighting in word cells for mission character display |
| app/words-docs/[id]/DocsDataPage.tsx | Added special handling for index pages (208, 223, 238) that return empty word data |
| app/words-docs/[id]/DocsDataHome.tsx | Added rendering for special index pages showing character grid with last update times, and fetching logic for character update times |
| } | ||
| } else if (239 <= name && name <= 252) { | ||
| const c = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하'][name - 239]; | ||
| const bit = misssionCharMask([c]); |
Copilot
AI
Dec 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function call contains a typo in the function name: "misssionCharMask" should be "missionCharMask" (three 's' instead of two).
| } else if (209 <= name && name <= 222) { | ||
| const c = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하'][name - 209]; | ||
| const bit = misssionCharMask([c]); | ||
| if (bit !== 0) { | ||
| const { data: missionKWordsData, error: missionKWordsError } = await this.supabase.rpc('get_mission_words', { target_mask: bit }) | ||
| if (missionKWordsError) return { data: null, error: missionKWordsError } | ||
|
|
||
| const grouped: Record<string, NonNullable<typeof missionKWordsData>> = {}; | ||
| (missionKWordsData || []).forEach(item => { | ||
| const f = item.word[0]; | ||
| if (!grouped[f]) grouped[f] = []; | ||
| grouped[f].push(item); | ||
| }); | ||
|
|
||
| const filteredWords: NonNullable<typeof missionKWordsData> = []; | ||
| Object.values(grouped).forEach(group => { | ||
| const multi = group.filter(w => w.word.split(c).length - 1 >= 2); | ||
| const single = group.filter(w => w.word.split(c).length - 1 === 1); | ||
|
|
||
| filteredWords.push(...multi); | ||
|
|
||
| if (multi.length < 10) { | ||
| const needed = 10 - multi.length; | ||
| single.sort((a, b) => { | ||
| const lenDiff = b.word.length - a.word.length; | ||
| if (lenDiff !== 0) return lenDiff; | ||
| return a.word.localeCompare(b.word); | ||
| }); | ||
| filteredWords.push(...single.slice(0, needed)); | ||
| } | ||
| }); | ||
|
|
||
| return { data: { words: filteredWords, waitWords: [] }, error: null } | ||
| } | ||
| } | ||
| else if (224 <= name && name <= 237) { | ||
| const c = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하'][name - 224]; | ||
| const bit = misssionCharMask([c]); | ||
| if (bit !== 0) { | ||
| const { data: missionKWordsData, error: missionKWordsError } = await this.supabase.rpc('get_mission_words', { target_mask: bit }) | ||
| if (missionKWordsError) return { data: null, error: missionKWordsError } | ||
|
|
||
| const grouped: Record<string, NonNullable<typeof missionKWordsData>> = {}; | ||
| (missionKWordsData || []).forEach(item => { | ||
| const f = item.word[item.word.length - 1]; | ||
| if (!grouped[f]) grouped[f] = []; | ||
| grouped[f].push(item); | ||
| }); | ||
|
|
||
| const filteredWords: NonNullable<typeof missionKWordsData> = []; | ||
| Object.values(grouped).forEach(group => { | ||
| const multi = group.filter(w => w.word.split(c).length - 1 >= 2); | ||
| const single = group.filter(w => w.word.split(c).length - 1 === 1); | ||
|
|
||
| filteredWords.push(...multi); | ||
|
|
||
| if (multi.length < 10) { | ||
| const needed = 10 - multi.length; | ||
| single.sort((a, b) => { | ||
| const lenDiff = b.word.length - a.word.length; | ||
| if (lenDiff !== 0) return lenDiff; | ||
| return a.word.localeCompare(b.word); | ||
| }); | ||
| filteredWords.push(...single.slice(0, needed)); | ||
| } | ||
| }); | ||
|
|
||
| return { data: { words: filteredWords, waitWords: [] }, error: null } | ||
|
|
||
| } | ||
| } else if (239 <= name && name <= 252) { | ||
| const c = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하'][name - 239]; | ||
| const bit = misssionCharMask([c]); | ||
| if (bit !== 0) { | ||
| const { data: missionKWordsData, error: missionKWordsError } = await this.supabase.rpc('get_mission_len3_words', { target_mask: bit }) | ||
| if (missionKWordsError) return { data: null, error: missionKWordsError } | ||
|
|
||
| const grouped: Record<string, NonNullable<typeof missionKWordsData>> = {}; | ||
| (missionKWordsData || []).forEach(item => { | ||
| const f = item.word[0]; | ||
| if (!grouped[f]) grouped[f] = []; | ||
| grouped[f].push(item); | ||
| }); | ||
|
|
||
| const filteredWords: NonNullable<typeof missionKWordsData> = []; | ||
| Object.values(grouped).forEach(group => { | ||
| const multi = group.filter(w => w.word.split(c).length - 1 >= 2); | ||
| const single = group.filter(w => w.word.split(c).length - 1 === 1); | ||
|
|
||
| filteredWords.push(...multi); | ||
|
|
||
| if (multi.length < 10) { | ||
| const needed = 10 - multi.length; | ||
| single.sort((a, b) => { | ||
| const lenDiff = b.word.length - a.word.length; | ||
| if (lenDiff !== 0) return lenDiff; | ||
| return a.word.localeCompare(b.word); | ||
| }); | ||
| filteredWords.push(...single.slice(0, needed)); | ||
| } | ||
| }); | ||
|
|
||
| return { data: { words: filteredWords, waitWords: [] }, error: null } | ||
| } |
Copilot
AI
Dec 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code block is duplicated three times (lines 215-248, 250-283, and 285-318) with only minor variations. The logic for fetching and filtering mission words is identical except for the character index calculation (name - 209, name - 224, name - 239) and the grouping field (word[0], word[word.length - 1], word[0]). Consider extracting this into a reusable helper function that accepts the character, the RPC function name, and the grouping strategy as parameters to improve maintainability and reduce code duplication.
| if (id === 208 || id === 223 || id === 238) { | ||
| const p = {title: docsData.name, lastUpdate: docsData.last_update, typez: docsData.typez} | ||
| setWordsData({words: [], metadata: p, starCount:docsStarData.map(({user_id})=>user_id)}); | ||
| await SCM.update().docView(docsData.id); | ||
| updateLoadingState(100, "완료!"); | ||
| return; | ||
| } |
Copilot
AI
Dec 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The special ID ranges and magic numbers (208, 223, 238, and the range conditions) are duplicated here and in other parts of the file. Consider defining these as named constants (e.g., MISSION_WORD_INDEX_IDS, MISSION_WORD_FIRST_RANGE, MISSION_WORD_LAST_RANGE, MISSION_WORD_LEN3_RANGE) to improve maintainability and reduce the chance of inconsistencies.
| const [activeTab, setActiveTab] = useState<TabType>("all"); | ||
| const user = useSelector((state: RootState) => state.user); | ||
| const user = useSelector((state: RootState) => state.user); | ||
| const specialIds = [208, 223, 238]; |
Copilot
AI
Dec 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The specialIds array is defined with specific values [208, 223, 238], but these same values are checked multiple times throughout the file and in DocsDataPage.tsx. This creates a maintenance burden where changes need to be synchronized across multiple locations. Consider defining this as a shared constant at module level or in a configuration file that can be imported where needed.
| } else if (209 <= name && name <= 222) { | ||
| const c = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하'][name - 209]; | ||
| const bit = misssionCharMask([c]); | ||
| if (bit !== 0) { | ||
| const { data: missionKWordsData, error: missionKWordsError } = await this.supabase.rpc('get_mission_words', { target_mask: bit }) | ||
| if (missionKWordsError) return { data: null, error: missionKWordsError } | ||
|
|
||
| const grouped: Record<string, NonNullable<typeof missionKWordsData>> = {}; | ||
| (missionKWordsData || []).forEach(item => { | ||
| const f = item.word[0]; | ||
| if (!grouped[f]) grouped[f] = []; | ||
| grouped[f].push(item); | ||
| }); | ||
|
|
||
| const filteredWords: NonNullable<typeof missionKWordsData> = []; | ||
| Object.values(grouped).forEach(group => { | ||
| const multi = group.filter(w => w.word.split(c).length - 1 >= 2); | ||
| const single = group.filter(w => w.word.split(c).length - 1 === 1); | ||
|
|
||
| filteredWords.push(...multi); | ||
|
|
||
| if (multi.length < 10) { | ||
| const needed = 10 - multi.length; | ||
| single.sort((a, b) => { | ||
| const lenDiff = b.word.length - a.word.length; | ||
| if (lenDiff !== 0) return lenDiff; | ||
| return a.word.localeCompare(b.word); | ||
| }); | ||
| filteredWords.push(...single.slice(0, needed)); | ||
| } | ||
| }); | ||
|
|
||
| return { data: { words: filteredWords, waitWords: [] }, error: null } | ||
| } | ||
| } | ||
| else if (224 <= name && name <= 237) { | ||
| const c = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하'][name - 224]; | ||
| const bit = misssionCharMask([c]); | ||
| if (bit !== 0) { | ||
| const { data: missionKWordsData, error: missionKWordsError } = await this.supabase.rpc('get_mission_words', { target_mask: bit }) | ||
| if (missionKWordsError) return { data: null, error: missionKWordsError } | ||
|
|
||
| const grouped: Record<string, NonNullable<typeof missionKWordsData>> = {}; | ||
| (missionKWordsData || []).forEach(item => { | ||
| const f = item.word[item.word.length - 1]; | ||
| if (!grouped[f]) grouped[f] = []; | ||
| grouped[f].push(item); | ||
| }); | ||
|
|
||
| const filteredWords: NonNullable<typeof missionKWordsData> = []; | ||
| Object.values(grouped).forEach(group => { | ||
| const multi = group.filter(w => w.word.split(c).length - 1 >= 2); | ||
| const single = group.filter(w => w.word.split(c).length - 1 === 1); | ||
|
|
||
| filteredWords.push(...multi); | ||
|
|
||
| if (multi.length < 10) { | ||
| const needed = 10 - multi.length; | ||
| single.sort((a, b) => { | ||
| const lenDiff = b.word.length - a.word.length; | ||
| if (lenDiff !== 0) return lenDiff; | ||
| return a.word.localeCompare(b.word); | ||
| }); | ||
| filteredWords.push(...single.slice(0, needed)); | ||
| } | ||
| }); | ||
|
|
||
| return { data: { words: filteredWords, waitWords: [] }, error: null } | ||
|
|
||
| } | ||
| } else if (239 <= name && name <= 252) { | ||
| const c = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하'][name - 239]; | ||
| const bit = misssionCharMask([c]); | ||
| if (bit !== 0) { | ||
| const { data: missionKWordsData, error: missionKWordsError } = await this.supabase.rpc('get_mission_len3_words', { target_mask: bit }) | ||
| if (missionKWordsError) return { data: null, error: missionKWordsError } | ||
|
|
||
| const grouped: Record<string, NonNullable<typeof missionKWordsData>> = {}; | ||
| (missionKWordsData || []).forEach(item => { | ||
| const f = item.word[0]; | ||
| if (!grouped[f]) grouped[f] = []; | ||
| grouped[f].push(item); | ||
| }); | ||
|
|
||
| const filteredWords: NonNullable<typeof missionKWordsData> = []; | ||
| Object.values(grouped).forEach(group => { | ||
| const multi = group.filter(w => w.word.split(c).length - 1 >= 2); | ||
| const single = group.filter(w => w.word.split(c).length - 1 === 1); | ||
|
|
||
| filteredWords.push(...multi); | ||
|
|
||
| if (multi.length < 10) { | ||
| const needed = 10 - multi.length; | ||
| single.sort((a, b) => { | ||
| const lenDiff = b.word.length - a.word.length; | ||
| if (lenDiff !== 0) return lenDiff; | ||
| return a.word.localeCompare(b.word); | ||
| }); | ||
| filteredWords.push(...single.slice(0, needed)); | ||
| } | ||
| }); | ||
|
|
||
| return { data: { words: filteredWords, waitWords: [] }, error: null } | ||
| } |
Copilot
AI
Dec 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The special ID ranges and logic are hardcoded in multiple places across the codebase. Consider extracting these magic numbers into named constants at the file or module level. For example: MISSION_WORD_FIRST_START = 209, MISSION_WORD_FIRST_END = 222, MISSION_WORD_LAST_START = 224, etc. This would improve code clarity and make it easier to maintain if these ranges need to change in the future.
| return { data: { words: wordsData.filter(({ word }) => !waitWordsData.some(w => word === w.word)), waitWords: waitWordsData }, error: null } | ||
| } else if (209 <= name && name <= 222) { | ||
| const c = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하'][name - 209]; | ||
| const bit = misssionCharMask([c]); |
Copilot
AI
Dec 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function call contains a typo in the function name: "misssionCharMask" should be "missionCharMask" (three 's' instead of two).
| } | ||
| else if (224 <= name && name <= 237) { | ||
| const c = ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하'][name - 224]; | ||
| const bit = misssionCharMask([c]); |
Copilot
AI
Dec 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function call contains a typo in the function name: "misssionCharMask" should be "missionCharMask" (three 's' instead of two).
| <Link href={`/word/search/${word}`} className="font-semibold text-gray-900 underline dark:text-gray-100"> | ||
| {isMission.m && isMission.t ? ( | ||
| word.split("").map((char, i) => ( | ||
| char === isMission.t ? <span key={i} className="text-green-500">{char}</span> : char |
Copilot
AI
Dec 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The character highlighting logic uses array index as the key prop in the map function. When the same character appears multiple times in a word, this could lead to React reconciliation issues. Consider using a more unique key like char-${i} or combining the index with another unique identifier.
| char === isMission.t ? <span key={i} className="text-green-500">{char}</span> : char | |
| char === isMission.t ? <span key={`${word}-${i}`} className="text-green-500">{char}</span> : char |
| if (errorMessage) return <ErrorPage message={errorMessage}/> | ||
|
|
||
| if (wordsData) return <DocsDataHome id={id} data={wordsData.words.sort((a,b)=>a.word.localeCompare(b.word,'ko'))} metaData={wordsData.metadata} starCount={wordsData.starCount}/> | ||
| if (wordsData) return <DocsDataHome id={id} data={wordsData.words.sort((a,b)=>a.word.localeCompare(b.word,'ko'))} metaData={wordsData.metadata} starCount={wordsData.starCount} isSpecial={(209 <= id && id <= 222) || (224<= id && id <= 237) || (239<= id && id <= 252)}/> |
Copilot
AI
Dec 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The magic number calculations for determining if an ID is "special" are complex and duplicated. The expression (209 <= id && id <= 222) || (224<= id && id <= 237) || (239<= id && id <= 252) would be clearer if extracted into a helper function or checked against named constants. This also appears in other parts of the file and in SupabaseClientManager.ts, creating maintenance burden.
| * @param chars 문자 배열 | ||
| * @returns 미션 문자 마스크 | ||
| */ | ||
| export function misssionCharMask(chars: string[]): number { |
Copilot
AI
Dec 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function name contains a typo: "misssionCharMask" should be "missionCharMask" (three 's' instead of two). This typo is also present in the import statement in SupabaseClientManager.ts and will need to be corrected consistently across all files where this function is imported or called.
Resolved #114