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.
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]); |
There was a problem hiding this comment.
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 } | ||
| } |
There was a problem hiding this comment.
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; | ||
| } |
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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 } | ||
| } |
There was a problem hiding this comment.
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]); |
There was a problem hiding this comment.
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]); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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)}/> |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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