From 438d29bdbb431ff785021626063242a269dbb0e1 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 20:53:41 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=B3=B4=EC=95=88=20=EB=93=B1?= =?UTF-8?q?=EA=B8=89=EB=B3=84=20=EB=A7=81=ED=81=AC=20=ED=98=84=ED=99=A9=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/analyses.ts | 10 +++ app/(tabs)/(home)/index.tsx | 146 +++++++++++++++++++++++++++++++----- 2 files changed, 137 insertions(+), 19 deletions(-) diff --git a/api/analyses.ts b/api/analyses.ts index d88a861..3f5bf9b 100644 --- a/api/analyses.ts +++ b/api/analyses.ts @@ -1,5 +1,6 @@ import { authenticatedApiRequest, + publicApiRequest, type ApiRequestOptions, type ClerkTokenGetter, } from '@/api/api-client'; @@ -30,6 +31,8 @@ export type AnalysisResponse = { errorMessage?: string; }; +export type VerdictStatisticsResponse = Record; + type AnalysisRequestOptions = Pick; export function requestAnalysis( @@ -55,3 +58,10 @@ export function fetchAnalysis( options, ); } + +export function fetchVerdictStatistics(options: AnalysisRequestOptions = {}) { + return publicApiRequest( + '/api/v1/analyses/statistics', + options, + ); +} diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index cec21b0..76e6320 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -15,11 +15,34 @@ import { IconSymbol } from '@/components/ui/icon-symbol'; import { SectionHeader } from '@/components/ui/section-header'; import { TitleEditModal } from '@/components/ui/title-edit-modal'; import { Toast } from '@/components/ui/toast'; +import { + fetchVerdictStatistics, + type AnalysisVerdict, + type VerdictStatisticsResponse, +} from '@/api/analyses'; import { Colors, Typography } from '@/constants/theme'; import { useFolders } from '@/context/folders-context'; import { getSavedLinkErrorMessage, useSavedLinks, type SavedLink } from '@/context/saved-links-context'; import type { AnchorPosition } from '@/components/ui/folder-card'; +type SecurityStatusItem = { + verdict: AnalysisVerdict; + label: string; + summary: string; +}; + +const SECURITY_STATUS_ITEMS: SecurityStatusItem[] = [ + { verdict: 'safe', label: '안전', summary: '문제 없음' }, + { verdict: 'caution', label: '주의', summary: '확인 필요' }, + { verdict: 'danger', label: '위험', summary: '접근 주의' }, +]; + +const EMPTY_STATISTICS: VerdictStatisticsResponse = { + safe: 0, + caution: 0, + danger: 0, +}; + export default function HomeScreen() { const { savedLinkToast: savedLinkToastParam } = useLocalSearchParams<{ savedLinkToast?: string | string[]; @@ -31,6 +54,9 @@ export default function HomeScreen() { const [saveToastVisible, setSaveToastVisible] = useState(false); const [deleteToastVisible, setDeleteToastVisible] = useState(false); const [titleToastVisible, setTitleToastVisible] = useState(false); + const [statistics, setStatistics] = useState(EMPTY_STATISTICS); + const [isStatisticsLoading, setIsStatisticsLoading] = useState(true); + const [statisticsErrorMessage, setStatisticsErrorMessage] = useState(''); const lastToastParamRef = useRef(undefined); const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; @@ -46,6 +72,37 @@ export default function HomeScreen() { setSaveToastVisible(true); }, [savedLinkToast]); + useEffect(() => { + const abortController = new AbortController(); + + async function loadStatistics() { + setIsStatisticsLoading(true); + setStatisticsErrorMessage(''); + + try { + const response = await fetchVerdictStatistics({ signal: abortController.signal }); + setStatistics(response); + } catch { + if (abortController.signal.aborted) { + return; + } + + setStatistics(EMPTY_STATISTICS); + setStatisticsErrorMessage('불러오지 못했어요'); + } finally { + if (!abortController.signal.aborted) { + setIsStatisticsLoading(false); + } + } + } + + void loadStatistics(); + + return () => { + abortController.abort(); + }; + }, []); + const handleMore = (id: number, anchor: AnchorPosition) => { setMenuState({ visible: true, anchor, linkId: id }); }; @@ -125,14 +182,44 @@ export default function HomeScreen() { {/* 보안 등급별 링크 현황 */} - - - - - - - - + {statisticsErrorMessage} + ) : undefined + } + /> + + {SECURITY_STATUS_ITEMS.map((item) => { + const colors = Colors.brand.verdict[item.verdict]; + const countLabel = isStatisticsLoading ? '-' : String(statistics[item.verdict]); + + return ( + + + + {item.label} + + + {countLabel} + + + {item.summary} + + + ); + })} @@ -249,26 +336,47 @@ const styles = StyleSheet.create({ gap: 12, }, - statPlaceholder: { + sectionStatus: { + ...Typography.bold12, + color: Colors.brand.textHint, + }, + + statCardGroup: { backgroundColor: Colors.brand.surface, borderRadius: 16, borderWidth: 1, borderColor: Colors.brand.line, padding: 16, - }, - statGrid: { flexDirection: 'row', - flexWrap: 'wrap', gap: 12, }, - statItem: { + statCard: { flex: 1, - minWidth: '45%', - height: 56, - borderRadius: 10, - borderWidth: 1.5, - borderColor: Colors.brand.line, - borderStyle: 'dashed', + minHeight: 112, + borderRadius: 14, + borderWidth: 1, + padding: 12, + justifyContent: 'space-between', + }, + statCardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + statIndicator: { + width: 8, + height: 8, + borderRadius: 4, + }, + statLabel: { + ...Typography.bold12, + }, + statCount: { + ...Typography.title, + lineHeight: 30, + }, + statSummary: { + ...Typography.regular12, }, linkList: { From bfc6d2ba5a393502aaf904d03316d478824eaa1b Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Tue, 26 May 2026 21:40:04 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EB=B3=B4=EC=95=88=20=EB=93=B1?= =?UTF-8?q?=EA=B8=89=20=ED=98=84=ED=99=A9=20=EC=83=81=ED=83=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/index.tsx | 75 +++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 76e6320..101633e 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -31,12 +31,20 @@ type SecurityStatusItem = { summary: string; }; +type StatisticsViewStatus = 'loading' | 'error' | 'empty' | 'ready'; + const SECURITY_STATUS_ITEMS: SecurityStatusItem[] = [ { verdict: 'safe', label: '안전', summary: '문제 없음' }, { verdict: 'caution', label: '주의', summary: '확인 필요' }, { verdict: 'danger', label: '위험', summary: '접근 주의' }, ]; +const STATISTICS_STATUS_LABELS: Record, string> = { + loading: '조회 중', + error: '불러오지 못했어요', + empty: '기록 없음', +}; + const EMPTY_STATISTICS: VerdictStatisticsResponse = { safe: 0, caution: 0, @@ -59,6 +67,14 @@ export default function HomeScreen() { const [statisticsErrorMessage, setStatisticsErrorMessage] = useState(''); const lastToastParamRef = useRef(undefined); const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; + const statisticsStatus = getStatisticsViewStatus( + statistics, + isStatisticsLoading, + statisticsErrorMessage, + ); + const statisticsStatusLabel = statisticsStatus === 'ready' + ? '' + : STATISTICS_STATUS_LABELS[statisticsStatus]; // 최근 저장한 링크 — createdAt 내림차순 상위 3개 const recentLinks = links.slice(0, 3); @@ -185,15 +201,20 @@ export default function HomeScreen() { {statisticsErrorMessage} + statisticsStatusLabel ? ( + {statisticsStatusLabel} ) : undefined } /> {SECURITY_STATUS_ITEMS.map((item) => { const colors = Colors.brand.verdict[item.verdict]; - const countLabel = isStatisticsLoading ? '-' : String(statistics[item.verdict]); + const countLabel = getStatisticsCountLabel( + item.verdict, + statistics, + statisticsStatus, + ); + const summary = getStatisticsSummary(item, statisticsStatus); return ( - {item.summary} + {summary} ); @@ -383,3 +404,49 @@ const styles = StyleSheet.create({ gap: 12, }, }); + +function getStatisticsViewStatus( + statistics: VerdictStatisticsResponse, + isLoading: boolean, + errorMessage: string, +): StatisticsViewStatus { + if (isLoading) { + return 'loading'; + } + + if (errorMessage) { + return 'error'; + } + + if (SECURITY_STATUS_ITEMS.every((item) => statistics[item.verdict] === 0)) { + return 'empty'; + } + + return 'ready'; +} + +function getStatisticsCountLabel( + verdict: AnalysisVerdict, + statistics: VerdictStatisticsResponse, + status: StatisticsViewStatus, +) { + return status === 'loading' || status === 'error' + ? '-' + : String(statistics[verdict]); +} + +function getStatisticsSummary(item: SecurityStatusItem, status: StatisticsViewStatus) { + if (status === 'loading') { + return '집계 중'; + } + + if (status === 'error') { + return '확인 불가'; + } + + if (status === 'empty') { + return '기록 없음'; + } + + return item.summary; +} From ef77b7bfed4bc095d3e11ab35025ac6b850f642c Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Fri, 29 May 2026 01:30:15 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=ED=86=B5=EA=B3=84=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EB=B9=88=20=EC=83=81=ED=83=9C=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/index.tsx | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 101633e..4c5f0a0 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -31,7 +31,7 @@ type SecurityStatusItem = { summary: string; }; -type StatisticsViewStatus = 'loading' | 'error' | 'empty' | 'ready'; +type StatisticsViewStatus = 'loading' | 'error' | 'ready'; const SECURITY_STATUS_ITEMS: SecurityStatusItem[] = [ { verdict: 'safe', label: '안전', summary: '문제 없음' }, @@ -42,7 +42,6 @@ const SECURITY_STATUS_ITEMS: SecurityStatusItem[] = [ const STATISTICS_STATUS_LABELS: Record, string> = { loading: '조회 중', error: '불러오지 못했어요', - empty: '기록 없음', }; const EMPTY_STATISTICS: VerdictStatisticsResponse = { @@ -68,13 +67,13 @@ export default function HomeScreen() { const lastToastParamRef = useRef(undefined); const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; const statisticsStatus = getStatisticsViewStatus( - statistics, isStatisticsLoading, statisticsErrorMessage, ); - const statisticsStatusLabel = statisticsStatus === 'ready' - ? '' - : STATISTICS_STATUS_LABELS[statisticsStatus]; + const hasNoStatistics = SECURITY_STATUS_ITEMS.every( + (item) => statistics[item.verdict] === 0, + ); + const statisticsStatusLabel = getStatisticsStatusLabel(statisticsStatus, hasNoStatistics); // 최근 저장한 링크 — createdAt 내림차순 상위 3개 const recentLinks = links.slice(0, 3); @@ -406,7 +405,6 @@ const styles = StyleSheet.create({ }); function getStatisticsViewStatus( - statistics: VerdictStatisticsResponse, isLoading: boolean, errorMessage: string, ): StatisticsViewStatus { @@ -418,11 +416,15 @@ function getStatisticsViewStatus( return 'error'; } - if (SECURITY_STATUS_ITEMS.every((item) => statistics[item.verdict] === 0)) { - return 'empty'; + return 'ready'; +} + +function getStatisticsStatusLabel(status: StatisticsViewStatus, hasNoStatistics: boolean) { + if (status === 'ready') { + return hasNoStatistics ? '기록 없음' : ''; } - return 'ready'; + return STATISTICS_STATUS_LABELS[status]; } function getStatisticsCountLabel( @@ -444,9 +446,5 @@ function getStatisticsSummary(item: SecurityStatusItem, status: StatisticsViewSt return '확인 불가'; } - if (status === 'empty') { - return '기록 없음'; - } - return item.summary; } From 63854be11796e188c864ebaef0e3dcbac58991a3 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Fri, 29 May 2026 01:37:32 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=ED=86=B5=EA=B3=84=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=83=81=ED=83=9C=20boolean=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/index.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 4c5f0a0..3bb3d4a 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -63,12 +63,12 @@ export default function HomeScreen() { const [titleToastVisible, setTitleToastVisible] = useState(false); const [statistics, setStatistics] = useState(EMPTY_STATISTICS); const [isStatisticsLoading, setIsStatisticsLoading] = useState(true); - const [statisticsErrorMessage, setStatisticsErrorMessage] = useState(''); + const [hasStatisticsError, setHasStatisticsError] = useState(false); const lastToastParamRef = useRef(undefined); const savedLinkToast = typeof savedLinkToastParam === 'string' ? savedLinkToastParam : undefined; const statisticsStatus = getStatisticsViewStatus( isStatisticsLoading, - statisticsErrorMessage, + hasStatisticsError, ); const hasNoStatistics = SECURITY_STATUS_ITEMS.every( (item) => statistics[item.verdict] === 0, @@ -92,7 +92,7 @@ export default function HomeScreen() { async function loadStatistics() { setIsStatisticsLoading(true); - setStatisticsErrorMessage(''); + setHasStatisticsError(false); try { const response = await fetchVerdictStatistics({ signal: abortController.signal }); @@ -103,7 +103,7 @@ export default function HomeScreen() { } setStatistics(EMPTY_STATISTICS); - setStatisticsErrorMessage('불러오지 못했어요'); + setHasStatisticsError(true); } finally { if (!abortController.signal.aborted) { setIsStatisticsLoading(false); @@ -406,13 +406,13 @@ const styles = StyleSheet.create({ function getStatisticsViewStatus( isLoading: boolean, - errorMessage: string, + hasError: boolean, ): StatisticsViewStatus { if (isLoading) { return 'loading'; } - if (errorMessage) { + if (hasError) { return 'error'; } From f4971b41828df642fb3d0b769510c60c85b728c4 Mon Sep 17 00:00:00 2001 From: KimByeongHun Date: Fri, 29 May 2026 01:44:25 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=ED=86=B5=EA=B3=84=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=83=81=ED=83=9C=20=EC=84=A4=EB=AA=85=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/index.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 3bb3d4a..cd9c349 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -213,7 +213,7 @@ export default function HomeScreen() { statistics, statisticsStatus, ); - const summary = getStatisticsSummary(item, statisticsStatus); + const summary = getStatisticsSummary(item); return ( Date: Fri, 29 May 2026 22:37:17 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=ED=99=88=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=EC=9E=AC?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/(home)/index.tsx | 55 +++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/app/(tabs)/(home)/index.tsx b/app/(tabs)/(home)/index.tsx index 781898d..0c92fe0 100644 --- a/app/(tabs)/(home)/index.tsx +++ b/app/(tabs)/(home)/index.tsx @@ -7,6 +7,7 @@ import { View, } from 'react-native'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; +import { useFocusEffect } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { router, useLocalSearchParams } from 'expo-router'; import { AppIcon } from '@/components/ui/app-icon'; @@ -95,36 +96,38 @@ export default function HomeScreen() { setSaveToastVisible(true); }, [savedLinkToast]); - useEffect(() => { - const abortController = new AbortController(); - - async function loadStatistics() { - setIsStatisticsLoading(true); - setHasStatisticsError(false); - - try { - const response = await fetchVerdictStatistics({ signal: abortController.signal }); - setStatistics(response); - } catch { - if (abortController.signal.aborted) { - return; - } - - setStatistics(EMPTY_STATISTICS); - setHasStatisticsError(true); - } finally { - if (!abortController.signal.aborted) { - setIsStatisticsLoading(false); + useFocusEffect( + useCallback(() => { + const abortController = new AbortController(); + + async function loadStatistics() { + setIsStatisticsLoading(true); + setHasStatisticsError(false); + + try { + const response = await fetchVerdictStatistics({ signal: abortController.signal }); + setStatistics(response); + } catch { + if (abortController.signal.aborted) { + return; + } + + setStatistics(EMPTY_STATISTICS); + setHasStatisticsError(true); + } finally { + if (!abortController.signal.aborted) { + setIsStatisticsLoading(false); + } } } - } - void loadStatistics(); + void loadStatistics(); - return () => { - abortController.abort(); - }; - }, []); + return () => { + abortController.abort(); + }; + }, []), + ); const handleMore = (id: number, anchor: AnchorPosition) => { setMenuState({ visible: true, anchor, linkId: id });