Skip to content
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import EducationPage from '@pages/EducationPage';
import QuizHomePage from '@pages/QuizHomePage.tsx';
import ProfilePage from '@pages/ProfilePage';
import QuizPage from '@pages/QuizPage.tsx';
import StatisticsPage from '@pages/StatisticsPage.tsx';
import { Global } from '@emotion/react';
import { globalStyles } from './styles/GlobalStyles';

Expand All @@ -20,6 +21,7 @@ function App() {
<Route path="/quiz-home" element={<QuizHomePage />} />
<Route path="/quiz" element={<QuizPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/statistics" element={<StatisticsPage />} />
</Routes>
<Global styles={globalStyles} />
</>
Expand Down
5 changes: 5 additions & 0 deletions src/components/common/BottomNavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const NavContainer = styled.nav`
padding: 10px 0;
border-top: 2px solid #4d403d;
font-family: 'Noto Sans KR', sans-serif;
z-index: 100;
`;

const StyledNavLink = styled(NavLink)`
Expand Down Expand Up @@ -60,6 +61,10 @@ const BottomNavBar = () => {
<Icon className="material-symbols-outlined">quiz</Icon>
<Label>퀴즈</Label>
</StyledNavLink>
<StyledNavLink to="/statistics">
<Icon className="material-symbols-outlined">bar_chart</Icon>
<Label>통계</Label>
</StyledNavLink>
<StyledNavLink to="/profile">
<Icon className="material-symbols-outlined">person</Icon>
<Label>프로필</Label>
Expand Down
60 changes: 60 additions & 0 deletions src/components/statistics/StatsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { CardCategory } from '@@types/index';
import styled from '@emotion/styled';
import { CARD_CATEGORY_KO, categoryMaterialIcons } from '../../utils';
import { GGAMJA_COLOR } from '../../styles/Colors';

const Container = styled.div`
background-color: white;
border: 2px solid ${GGAMJA_COLOR.DARK_BROWN};
border-radius: 8px;
padding: 10px;
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
aspect-ratio: 1 / 1;
box-shadow: 4px 4px 0 ${GGAMJA_COLOR.DARK_BROWN};
gap: 5px;
`;

const Icon = styled.span`
font-size: 30px;
color: ${GGAMJA_COLOR.DARK_BROWN};
`;

const CardTitle = styled.h3`
font-size: 20px;
font-weight: 700;
color: ${GGAMJA_COLOR.DARK_BROWN};
margin: 8px 0 4px 0;
`;

const StatsInfo = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
color: ${GGAMJA_COLOR.LIGHT_BROWN};
`;

interface StatsCardProps {
category: CardCategory;
quizLogCount: number;
cardLogCount: number;
}

const StatsCard = ({ category, quizLogCount, cardLogCount }: StatsCardProps) => {
return (
<Container>
<Icon className="material-symbols-outlined">{categoryMaterialIcons[category] || 'help'}</Icon>
<CardTitle>{CARD_CATEGORY_KO[category]}</CardTitle>
<StatsInfo>
<div>퀴즈: {quizLogCount}회</div>
<div>학습: {cardLogCount}회</div>
</StatsInfo>
</Container>
);
};

export default StatsCard;
97 changes: 97 additions & 0 deletions src/components/statistics/StatsGrpah.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import styled from '@emotion/styled';
import { CARD_CATEGORY_KO } from '@utils/index';
import type { CardCategory } from '@@types/index';
import { GGAMJA_COLOR } from '../../styles/Colors';

interface CategoryData {
category: CardCategory;
accuracy: number;
}

interface StatsGraphProps {
data: CategoryData[];
}

const Container = styled.div`
width: 100%;
height: 200px;
display: flex;
align-items: end;
justify-content: space-around;
gap: 8px;
padding: 20px 0;
`;

const GraphItem = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex: 1;
height: 100%;
`;

const BarContainer = styled.div`
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: end;
`;

const CharacterImage = styled.img<{ barHeight: number }>`
width: 100%;
max-width: 30px;
height: 100%;
max-height: 30px;
object-fit: contain;
margin-bottom: ${({ barHeight }) => (barHeight === 0 ? '0px' : '-10px')};
z-index: 1;
`;

const Bar = styled.div<{ height: number }>`
width: 100%;
max-width: 20px;
background-color: ${GGAMJA_COLOR.GREEN};
border-radius: 8px;
height: ${({ height }) => height}%;
transition: height 0.3s ease;
min-height: 0;
`;

const CategoryName = styled.span`
font-size: 14px;
font-weight: 600;
color: ${GGAMJA_COLOR.DARK_BROWN};
text-align: center;
text-orientation: mixed;
`;

const AccuracyText = styled.span`
font-size: 12px;
font-weight: 500;
color: ${GGAMJA_COLOR.DARK_BROWN};
text-align: center;
`;

const StatsGraph = ({ data }: StatsGraphProps) => {
return (
<Container>
{data.map((item, index) => (
<GraphItem key={index}>
<BarContainer>
<CharacterImage src="https://ggamja-images.s3.ap-northeast-2.amazonaws.com/level1.png" barHeight={item.accuracy} />
<Bar height={item.accuracy} />
</BarContainer>
<CategoryName>{CARD_CATEGORY_KO[item.category]}</CategoryName>
<AccuracyText>{item.accuracy}%</AccuracyText>
</GraphItem>
))}
</Container>
);
};

export default StatsGraph;
14 changes: 14 additions & 0 deletions src/hooks/useMonthlyAnalysis.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import useApi from '@hooks/useApi';
import type { ApiResponse, MonthlyAnalysisResponse } from '@@types/index.ts';

function useMonthlyAnalysis() {
const { api } = useApi();

const fetchMonthlyAnalysis = () => {
return api.get<ApiResponse<MonthlyAnalysisResponse>>(`/analysis/categories/monthly`).then((response) => response.data.data);
};

return { fetchMonthlyAnalysis };
}

export default useMonthlyAnalysis;
162 changes: 162 additions & 0 deletions src/pages/StatisticsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react';
import BottomNavBar from '@components/common/BottomNavBar.tsx';
import StatsCard from '@components/statistics/StatsCard.tsx';
import StatsGraph from '@components/statistics/StatsGrpah.tsx';
import useMonthlyAnalysis from '@hooks/useMonthlyAnalysis.tsx';
import styled from '@emotion/styled';
import { GGAMJA_COLOR } from '../styles/Colors.ts';
import type { CardCategory } from '@@types/index.ts';
import { CARD_CATEGORY_KO } from '@utils/index.ts';

const PageWrapper = styled.div`
background-color: #faf8f5;
color: ${GGAMJA_COLOR.DARK_BROWN};
padding-bottom: 80px;
height: 100%;
overflow: auto;
`;

const Container = styled.div`
padding: 40px 20px 20px 20px;
`;

const Header = styled.header`
text-align: center;
`;

const PageTitle = styled.h1`
font-size: 36px;
font-weight: 700;
color: ${GGAMJA_COLOR.DARK_BROWN};
`;

const StatisticsContentWrapper = styled.div`
border: 1px solid ${GGAMJA_COLOR.DARK_BROWN};
border-radius: 10px;
padding: 40px 20px 40px 20px;
display: flex;
flex-direction: column;
align-items: center;
aspect-ratio: 1 / 1;
box-shadow: 4px 4px 0 ${GGAMJA_COLOR.DARK_BROWN};
gap: 10px;
`;

const StatsCardGridContainer = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 20px;
width: 100%;
align-items: center;
`;

const StatisticsContent = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
border: 1px solid ${GGAMJA_COLOR.DARK_BROWN};
border-radius: 10px;
gap: 10px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 4px 4px 0 ${GGAMJA_COLOR.DARK_BROWN};
`;

const StatisticsTitle = styled.h2`
font-size: 24px;
font-weight: 700;
width: 100%;
color: ${GGAMJA_COLOR.DARK_BROWN};
text-align: left;
`;

const StatisticsTextContainer = styled.div<{ isSurpassed: boolean }>`
font-size: 20px;
font-weight: 400;
width: 100%;
color: ${GGAMJA_COLOR.DARK_BROWN};
`;

const StatisticsCategory = styled.h3`
display: inline;
font-weight: 700;
`;

const StatisticsPage = () => {
const { fetchMonthlyAnalysis } = useMonthlyAnalysis();
const [nickname, setNickname] = useState<string>('사용자');
const [categories, setCategories] = useState<{ category: CardCategory; quizLogCount: number; cardLogCount: number; accuracy: number }[]>([]);
const [strengths, setStrengths] = useState<CardCategory[]>([]);
const [weaknesses, setWeaknesses] = useState<CardCategory[]>([]);
const [allEqual, setAllEqual] = useState(false);

useEffect(() => {
const loadStatistics = async () => {
const statisticsData = await fetchMonthlyAnalysis();
setNickname(statisticsData.nickname);
setCategories(statisticsData.categories);
setStrengths(statisticsData.strengths);
setWeaknesses(statisticsData.weaknesses);
setAllEqual(statisticsData.allEqual);
};

loadStatistics();
}, []);

const renderStatisticsContent = () => {
if (!allEqual) {
return [
{ data: strengths, isSurpassed: true, message: '분야에 강하시네요!' },
{ data: weaknesses, isSurpassed: false, message: '분야를 조금 더 공부해보아요!' },
].map(
({ data, isSurpassed, message }) =>
data.length > 0 && (
<StatisticsTextContainer key={message} isSurpassed={isSurpassed}>
{data.map((category, index) => (
<StatisticsCategory key={index}>
{CARD_CATEGORY_KO[category as CardCategory]}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
{CARD_CATEGORY_KO[category as CardCategory]}
{CARD_CATEGORY_KO[category]}

{index < data.length - 1 ? ', ' : ''}
</StatisticsCategory>
))}
{message}
</StatisticsTextContainer>
),
);
}
const isNeverStudied = categories[0].accuracy == 0;
return isNeverStudied ? '지금 당장 공부하러 가볼까요?' : '모든 카테고리의 정답률이 동일합니다.';
};

return (
<PageWrapper>
<Container>
<Header>
<PageTitle>이번 달 통계</PageTitle>
</Header>
<StatisticsContentWrapper>
<StatisticsTitle>카테고리 분석</StatisticsTitle>
<StatisticsContent>
<StatsCardGridContainer>
{categories.map((item, index) => (
<StatsCard key={index} category={item.category} quizLogCount={item.quizLogCount} cardLogCount={item.cardLogCount} />
))}
</StatsCardGridContainer>
</StatisticsContent>
<StatisticsTitle>정답률 분석</StatisticsTitle>
<StatisticsContent>
<StatsGraph data={categories} />
</StatisticsContent>
<StatisticsTitle>{nickname}님, 이번 달에는 ...</StatisticsTitle>
<StatisticsContent>{renderStatisticsContent()}</StatisticsContent>
</StatisticsContentWrapper>
<BottomNavBar />
</Container>
</PageWrapper>
);
};

export default StatisticsPage;
15 changes: 15 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,18 @@ export interface AttendanceResponse {
];
allCompleted: boolean;
}

export interface MonthlyAnalysisResponse {
nickname: string;
categories: [
{
category: CardCategory;
quizLogCount: number;
cardLogCount: number;
accuracy: number;
},
];
strengths: CardCategory[];
weaknesses: CardCategory[];
allEqual: boolean;
}