refactor - QA반영#17
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements multiple UX improvements and refactoring changes focused on user profile management, notification settings, and authentication flow. The changes add push token synchronization, enforce 5-minute intervals for notification times, restrict fortune data access to dates after January 1, 2026, and improve the login/signup experience.
Changes:
- Added push token service to sync device tokens with the backend via
/users/me/token - Implemented 5-minute interval validation for notification times
- Added minimum date restriction (2026-01-01) for fortune data access
- Refactored authentication flow to use persistent login state and profile fetching
- Fixed input text clipping issues and improved UI consistency
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/fortune.ts | Updated comment to document 5-minute increment requirement for notification time |
| src/services/userProfileService.ts | Added validation function to enforce 5-minute intervals on notification times; moved payload construction inside try block |
| src/services/pushTokenService.ts | New service to handle push token synchronization with backend API |
| src/components/UserInfoForm.tsx | Added 5-minute step validation for notification time picker; fixed input text sizing |
| src/components/SettingsSheet.tsx | Refactored to use callback props for logout/unauthorized; added notification time picker with 5-minute intervals |
| src/components/LoginScreen.tsx | Added INPUT_STYLE constant and fixed text alignment to prevent clipping |
| src/components/CalendarPage.tsx | Added MIN_FORTUNE_DATE constant to restrict viewing dates before 2026-01-01 |
| app/index.tsx | Implemented persistent login state, automatic profile fetching on sign-in, and push token synchronization |
| app.config.ts | Bumped version to 1.0.5 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if ( | ||
| !Number.isFinite(hour) || | ||
| !Number.isFinite(minute) || | ||
| hour < 0 || | ||
| hour > 23 || | ||
| minute < 0 || | ||
| minute > 59 | ||
| ) { | ||
| throw new ProfileApiError('알림 시간 형식이 올바르지 않습니다.'); | ||
| } |
There was a problem hiding this comment.
The validation logic checks if hour and minute are negative, but Number() conversion of strings like "00" or "01" will never be negative. The real issue is checking if hour/minute are less than 0 after they've been successfully parsed from a regex match of digits. Consider removing the unnecessary negative checks (hour < 0, minute < 0) since the regex pattern /^(\d{1,2}):(\d{1,2})$/ already ensures non-negative integers.
| if ( | ||
| !Number.isFinite(hourNumber) || | ||
| !Number.isFinite(minuteNumber) || | ||
| hourNumber > 23 || | ||
| minuteNumber > 59 | ||
| ) { | ||
| return { hour: fallbackHour, minute: fallbackMinute }; | ||
| } | ||
| return { hour, minute }; |
There was a problem hiding this comment.
The same validation logic issue exists here - checking if hour/minute are less than 0 after regex parsing is redundant since the regex /(\d{1,2}):(\d{1,2})/ already ensures non-negative digits. Consider removing the hour < 0 and minute < 0 checks.
| const requireLogin = useCallback(() => { | ||
| setForceLogin(true); | ||
| setIsSettingsOpen(false); | ||
| void signOut(); | ||
| }, [signOut]); |
There was a problem hiding this comment.
The requireLogin function sets forceLogin to true and calls signOut, but it doesn't clear the HAS_LOGGED_IN_KEY from AsyncStorage or reset hasLoggedIn state. This means after logout, the persisted login state remains true. Consider adding await AsyncStorage.removeItem(HAS_LOGGED_IN_KEY) and setHasLoggedIn(false) to properly clear the login state when requiring re-authentication.
| const ITEM_HEIGHT = 36; | ||
| const VISIBLE_ITEMS = 5; | ||
| const DEFAULT_NOTIFICATION_TIME = '08:00'; | ||
| const NOTIFICATION_MINUTE_STEP = 5; | ||
|
|
||
| const pad2 = (value: number) => String(value).padStart(2, '0'); | ||
|
|
||
| const parseTimeParts = (value: string, fallback: string) => { | ||
| const [fallbackHour, fallbackMinute] = fallback.split(':'); | ||
| const match = value.match(/(\d{1,2}):(\d{1,2})/); | ||
| if (!match) return { hour: fallbackHour, minute: fallbackMinute }; | ||
| const hourNumber = Number(match[1]); | ||
| const minuteNumber = Number(match[2]); | ||
| const hour = pad2(hourNumber); | ||
| const minute = pad2(minuteNumber); | ||
| if ( | ||
| !Number.isFinite(hourNumber) || | ||
| !Number.isFinite(minuteNumber) || | ||
| hourNumber > 23 || | ||
| minuteNumber > 59 | ||
| ) { | ||
| return { hour: fallbackHour, minute: fallbackMinute }; | ||
| } | ||
| return { hour, minute }; | ||
| }; | ||
|
|
||
| const normalizeMinuteToStep = (minute: string, step: number) => { | ||
| const numeric = Number(minute); | ||
| if (!Number.isFinite(numeric)) return minute; | ||
| const normalized = Math.floor(numeric / step) * step; | ||
| const bounded = Math.min(Math.max(normalized, 0), 59); | ||
| return pad2(bounded); | ||
| }; | ||
|
|
||
| const WheelPicker: React.FC<{ | ||
| options: string[]; | ||
| value: string; | ||
| onChange: (value: string) => void; | ||
| itemTextClassName?: string; | ||
| }> = ({ options, value, onChange, itemTextClassName = 'text-base' }) => { | ||
| const scrollRef = useRef<ScrollView | null>(null); | ||
| const padding = ((VISIBLE_ITEMS - 1) / 2) * ITEM_HEIGHT; | ||
|
|
||
| useEffect(() => { | ||
| if (!options.length) return; | ||
| const index = Math.max(0, options.indexOf(value)); | ||
| scrollRef.current?.scrollTo({ y: index * ITEM_HEIGHT, animated: false }); | ||
| }, [options, value]); | ||
|
|
||
| const handleScrollEnd = (event: { nativeEvent: { contentOffset: { y: number } } }) => { | ||
| if (!options.length) return; | ||
| const offsetY = event.nativeEvent.contentOffset.y; | ||
| const index = Math.round(offsetY / ITEM_HEIGHT); | ||
| const bounded = Math.max(0, Math.min(index, options.length - 1)); | ||
| const nextValue = options[bounded]; | ||
| if (nextValue !== value) onChange(nextValue); | ||
| }; | ||
|
|
||
| return ( | ||
| <View className="overflow-hidden" style={{ height: ITEM_HEIGHT * VISIBLE_ITEMS }}> | ||
| <ScrollView | ||
| ref={scrollRef} | ||
| showsVerticalScrollIndicator={false} | ||
| snapToInterval={ITEM_HEIGHT} | ||
| decelerationRate="fast" | ||
| onMomentumScrollEnd={handleScrollEnd} | ||
| onScrollEndDrag={handleScrollEnd} | ||
| nestedScrollEnabled | ||
| contentContainerStyle={{ paddingVertical: padding }} | ||
| > | ||
| {options.map((option) => { | ||
| const isSelected = option === value; | ||
| return ( | ||
| <View | ||
| key={option} | ||
| style={{ height: ITEM_HEIGHT }} | ||
| className="items-center justify-center" | ||
| > | ||
| <Text | ||
| className={`${isSelected ? 'text-gray-900' : 'text-gray-400'} ${itemTextClassName} `} | ||
| > | ||
| {option} | ||
| </Text> | ||
| </View> | ||
| ); | ||
| })} | ||
| </ScrollView> | ||
| <View | ||
| pointerEvents="none" | ||
| style={{ | ||
| position: 'absolute', | ||
| left: 0, | ||
| right: 0, | ||
| top: padding, | ||
| height: ITEM_HEIGHT, | ||
| borderTopWidth: 1, | ||
| borderBottomWidth: 1, | ||
| borderColor: '#e5e7eb', | ||
| }} | ||
| /> | ||
| </View> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
There is significant code duplication between UserInfoForm and SettingsSheet components. Both define identical helper functions (pad2, parseTimeParts, normalizeMinuteToStep), constants (ITEM_HEIGHT, VISIBLE_ITEMS, NOTIFICATION_MINUTE_STEP), and the WheelPicker component. Consider extracting these shared utilities and components into separate files to improve maintainability and reduce code duplication.
| const handleLogout = () => { | ||
| onLogout(); | ||
| }; |
There was a problem hiding this comment.
The handleLogout function has been changed from async to synchronous and now only calls onLogout(). However, the component still uses isLoading from useAuth to disable the logout button (line 379, not shown in diff). Since handleLogout no longer performs async operations directly, the isLoading state from useAuth may not accurately represent the logout operation anymore. Consider reviewing whether this isLoading check is still needed.
| <Pressable className="rounded-xl bg-gray-100 px-4 py-4" onPress={openBirthDateModal}> | ||
| <View className="flex-row items-center justify-between"> | ||
| <Text className="text-2xl font-semibold text-gray-900">{birthSummary}</Text> | ||
| <Text className="text-lg font-semibold text-gray-900">{birthSummary}</Text> |
There was a problem hiding this comment.
The text size for the birth date summary was changed from text-2xl to text-lg, which is a reduction from 1.5rem (24px) to 1.125rem (18px). This is a significant visual change that affects consistency with other parts of the form. Consider verifying this matches the design requirements and is consistent with other similar fields in the form.
작업내용
users/me/token으로 서버에 푸시알림 토큰 전달users/mePATCH로 사용자 정보 업데이트 및 유저정보페이지 라우팅users/meGET으로 사용자 정보 조회 및 메인화면으로 라우팅#14