-
Notifications
You must be signed in to change notification settings - Fork 6
Dashboard
butterbeetle edited this page Aug 21, 2024
·
4 revisions
대시보드는 건강과 운동 여정의 전반적인 개요를 제공합니다.
참여한 챌린지와 운동, 식단, 체중 변화를 모니터링할 수 있습니다.
-
레벨 표시
- 현재 레벨과 다음 레벨까지의 진행 상황을 확인할 수 있습니다.
-
내가 참여한 챌린지 확인
- 참여한 챌린지 목록과 각 챌린지의 상태를 확인할 수 있습니다.
-
운동 확인
- 최근의 운동 기록을 요약하여 보여줍니다.
-
식단 확인
- 최근 섭취한 식단을 요약하여 보여줍니다.
-
체중 변화 추이 그래프
- 시간에 따른 체중 변화를 시각적으로 나타낸 그래프입니다.
대시보드
// Promise.all을 사용해서 병렬적으로 데이터를 받아옵니다.
await Promise.all([
queryClient.prefetchQuery(levelQueryOptions.getLevel(supabase)),
queryClient.prefetchQuery({
queryKey: ['weights'],
queryFn: () => api.dashboard.getWeights(supabase, query),
}),
queryClient.prefetchQuery({
queryKey: ['diets', { date: format(new Date(), 'yyyy-MM-dd') }],
queryFn: () => api.dashboard.getDiets(supabase, new Date()),
}),
queryClient.prefetchQuery({
queryKey: ExercisesQueryKeys.detail(getFormattedDate(subDays(new Date(), 1))),
queryFn: () => api.dashboard.getExercises(supabase, subDays(new Date(), 1)),
}),
queryClient.prefetchQuery({
queryKey: ExercisesQueryKeys.detail(getFormattedDate(new Date())),
queryFn: () => api.dashboard.getExercises(supabase, new Date()),
}),
queryClient.prefetchQuery({
queryKey: ExercisesQueryKeys.detail(getFormattedDate(addDays(new Date(), 1))),
queryFn: () => api.dashboard.getExercises(supabase, addDays(new Date(), 1)),
}),
queryClient.prefetchQuery({
queryKey: ['joinedChallenge'],
queryFn: () => api.dashboard.getJoinedChallenges(supabase),
}),
]);
// 필요한곳에는 hydration을 사용하여 client에서 바로 데이터를 사용 가능하게 해줍니다.
<HydrationBoundary state={dehydrate(queryClient)}>
<DashBoardLevel />
</HydrationBoundary>
레벨 표시
// 레벨 정보 가져오는 함수
const getLevel = async (client: SupabaseClient<Database>) => {
try {
const {
data: { user },
} = await client.auth.getUser();
if (!user) {
console.error('User not found');
return;
}
const response = await client
.from('users')
.select('level, experience, expInfo:level(experience)')
.eq('id', user.id)
.single<ExpInfoType>();
return response.data;
} catch (error) {
const err = error as Error;
console.error('ERROR___', err);
throw err;
}
};
// 레벨 쿼리
export const levelQueryOptions = {
getLevel: (client: SupabaseClient<Database>) => ({
queryKey: levelQueryKeys.level,
queryFn: () => api.level.getLevel(client),
}),
};
// 현재 레벨 정보 훅
export const useGetLevel = (client: SupabaseClient<Database>) => useQuery(levelQueryOptions.getLevel(client));
// 사용 예시
const { data: levelData, isLoading, isError } = useGetLevel(supabase);
// 레벨 계산
const { level: curLevel, experience: curExperience } = levelData;
const { experience: requiredExperience } = levelData.expInfo;
// 화면에 표시
<>
<div className="w-8 h-full absolute bg-gradient-to-r from-[#12121266] to-[#12121201] top-0 left-0"></div>
<div className="w-8 h-full absolute bg-gradient-to-l from-[#12121266] to-[#12121201] top-0 right-0"></div>
<div className="absolute left-4 top-4 gap-y-1 grid">
<h5 className="text-white/50 text-sm">헬린이</h5>
<h6 className="text-[28px]">{`Lv.${curLevel}`}</h6>
<div className="flex text-white/40 text-[10px] gap-x-1">
다음 레벨까지
<p className="text-primary-100">{`${(100 - (curExperience / requiredExperience) * 100).toFixed(0)}%`}</p>
</div>
</div>
<div className="absolute bottom-9">
<div className="-rotate-[15deg] flex w-[100px] justify-center items-center relative ">
<div className="absolute -left-[95px] -bottom-[11px] -rotate-[15deg] bg-[#37cc85] w-[100px] h-2" />
<div className="absolute -left-2 rounded-full bg-[#5effb2] p-px size-4 z-10 flex justify-center items-center">
<div
className="size-full rounded-full bg-[#37cc85] border-[1px] border-white/60"
style={{ filter: 'url(#glow)' }}
/>
</div>
<LevelProgress experience={(curExperience / requiredExperience) * 100} />
<div className="absolute -right-2 rounded-full bg-white/10 p-px size-4 z-10 flex justify-center items-center">
<div className="size-full rounded-full bg-[#292929] border-[1px] border-white/20" />
</div>
<div className="absolute -right-[95px] -top-[11px] -rotate-[15deg] bg-white/5 w-[100px] h-2" />
</div>
</div>
</>
// rechart를 사용해서 바 차트를 그립니다.
<ResponsiveContainer width="100%" height={10}>
<BarChart layout="vertical" data={data} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<XAxis type="number" hide domain={[0, 100]} />
<YAxis type="category" dataKey="name" hide />
<Bar
fill="#12F287"
filter="url(#glow)"
background={(props: any) => {
const { x, y, width, height } = props;
return (
<rect
x={x}
y={y + 1}
width={width}
height={height - 2}
fill="#ffffff26"
rx={10}
ry={10}
filter="url(#glow)"
/>
);
}}
dataKey="value"
radius={[10, 10, 10, 10]}
/>
</BarChart>
</ResponsiveContainer>
// 특정 행동 시 경험치를 획득할 수 있습니다.
// 아래 예로 챌린지 인증 시 경험치를 10 얻을 수 있습니다.
verify(verifyData, {
onSuccess: () => {
modal.alert(['등록되었습니다.']);
// 인증 등록 성공 시 경험치를 10 올려줍니다.
levelUp({ exp: 10 });
queryClient.invalidateQueries({ queryKey: ['verifications', { cid: cid }] });
router.replace(`/challenges/${cid}/verification/list`);
},
onError: (error) => {
modal.alert(['등록에 실패하였습니다.']);
console.error('Chaalenge Verify Failed', error);
},
});
내가 참여한 챌린지 확인
// 참여중인 챌린지 데이터
getJoinedChallenges = async (client: SupabaseClient<Database>) => {
try {
const {
data: { user },
} = await client.auth.getUser();
if (!user) {
return {
data: null,
error: 'User not found',
details: 'User not found',
};
}
const { data: challenges, error } = await client
.from('challengeParticipants')
.select('*,challenges(title, isProgress)')
.eq('userId', user?.id);
if (error) {
console.error('Challenge Participants Database query error:', error);
return {
data: null,
error: 'Challenge Participants Database query failed',
details: error.message,
};
}
return { data: challenges, error: null, details: null };
} catch (error) {
console.error('Unexpected error:', error);
return { data: null, error: 'Unexpected error occurred', details: (error as Error).message };
}
};
// useQuery로 참여중인 챌린지 정보 가져오기
const { data: joinedChallenges, error } = useQuery({
queryKey: ['joinedChallenge'],
queryFn: () => api.dashboard.getJoinedChallenges(supabase),
});
// 화면에 현재 "RUN" (진행) 중인 챌린지만 표시
<JoinedChallengesLayout>
<ul className="w-full text-sm text-white grid grid-cols-1 sm:grid-cols-2 gap-y-5">
{joinedChallenges.data.slice(0, 6).map(({ id, challenges, challengeId }) => {
if (challenges?.isProgress === 'RUN') {
return (
<Link
className="w-full line-clamp-1 h-5 break-words hover:font-bold"
key={id}
href={`/challenges/${challengeId}/detail`}
>
<li>{challenges?.title || '제목 없음'}</li>
</Link>
);
}
})}
</ul>
</JoinedChallengesLayout>
운동 기록 확인
// 사용자에게 더 좋은 경험을 주기위해 선택한 날 이전,이후 데이터도 같이 가져와 캐싱합니다
const [_h, result, _h2] = useQueries({
queries: [
{
queryKey: ExercisesQueryKeys.detail(getFormattedDate(subDays(date, 1))),
queryFn: () => api.dashboard.getExercises(supabase, subDays(date, 1)),
enabled: !!user,
},
{
queryKey: ExercisesQueryKeys.detail(getFormattedDate(date)),
queryFn: () => api.dashboard.getExercises(supabase, date),
enabled: !!user,
},
{
queryKey: ExercisesQueryKeys.detail(getFormattedDate(addDays(date, 1))),
queryFn: () => api.dashboard.getExercises(supabase, addDays(date, 1)),
enabled: !!user,
},
],
});
// 가져온 데이터는 완료여부에 따라 정렬됩니다.
<ul className="size-full grid gap-y-5">
{exercises.data
.slice(0, 5)
.sort((a, b) => Number(a.isCompleted) - Number(b.isCompleted))
.map((exercise) => (
<li key={exercise.id}>
<ExerciseTodoItem exercise={exercise} date={date} />
</li>
))}
</ul>
식단 기록 확인
// 선택한 날짜의 식단 데이터 가져오기
const { data: diets } = useQuery({
queryKey: ['diets', { date: format(date, 'yyyy-MM-dd') }],
queryFn: () => api.dashboard.getDiets(supabase, date),
enabled: !!user,
});
// 칼로리 및 음식 데이터 정리
const calories = getDietsCalories(diets?.data);
const foods = getFoods(diets?.data);
// 화면에 데이터를 그려줍니다.
<div className="w-full">
<div className="flex justify-between items-center h-[44px] relative">
<div className="border-l-4 border-[#03C717]/80 w-1/2 absolute top-0 -left-4 bottom-0 right-0 bg-gradient-to-r from-[#12f287]/10 to-white/0" />
<p className="font-semibold text-sm text-[#12F287]">칼로리</p>
<div className="flex gap-x-1 items-end">
<p className="text-white font-bold text-lg">{calories.kcal.toLocaleString()}</p>
<p className="text-white/30 text-[12px]">Kcal</p>
</div>
</div>
<div className="flex justify-between items-center h-[44px]">
<p className="font-semibold text-sm text-white/30">탄수화물</p>
<div className="flex gap-x-1 items-center">
<p className="text-white font-bold text-lg">{calories.carbohydrate}</p>
<p className="text-white/30 text-[12px]">g</p>
</div>
</div>
<div className="flex justify-between items-center h-[44px]">
<p className="font-semibold text-sm text-white/30">단백질</p>
<div className="flex gap-x-1 items-center">
<p className="text-white font-bold text-lg">{calories.protein}</p>
<p className="text-white/30 text-[12px]">g</p>
</div>
</div>
<div className="flex justify-between items-center h-[44px] ">
<p className="font-semibold text-sm text-white/30">지방</p>
<div className="flex gap-x-1 items-center">
<p className="text-white font-bold text-lg">{calories.fat}</p>
<p className="text-white/30 text-[12px]">g</p>
</div>
</div>
</div>
// Swiper 라이브러리를 사용해서 자연스럽게 드래그할 수 있게 해줍니다.
{
foods && foods.length > 0 && (
<div className="w-full border-t pt-4 border-white/10 text-white">
<Swiper
slidesPerView="auto"
spaceBetween={16}
freeMode={true}
mousewheel={true}
modules={[FreeMode, Mousewheel]}
className="!flex !justify-start !mx-0 !w-full"
>
{foods.map((food) => (
<SwiperSlide key={food.id} className="!w-auto !flex-shrink-0">
<Chip food={food} />
</SwiperSlide>
))}
</Swiper>
</div>
)
}
체중 변화 추이 그래프
// 더미 데이터 만들기
// 그래프 특성상 최소 2~3일의 데이터를 입력해야 하므로 당장 그래프를 보여주기 어려워서
// 더미 데이터로 대신하기 위함입니다.
let tmp = [];
for (let i = 0; i <= 30; i++) {
tmp.push({
id: i + 1,
date: format(subDays(new Date(), 30 - i), 'yyyy-MM-dd'),
weight: Math.floor(Math.random() * 20 + 60),
});
}
// 더미 데이터 뿐만아니라 실제 데이터도 받아옵니다.
const { data: weights, error } = useQuery({
queryKey: ['weights'],
queryFn: () => api.dashboard.getWeights(supabase, query),
enabled: !!user,
});
// 반응형에 대응하기위해 useEffect를 사용해서 화면크기에 따라 데이터 길이를 설정합니다.
useEffect(() => {
setIsLoading(false);
const handleResize = () => {
const width = window.innerWidth;
if (width >= 1280) {
setLength(16);
} else if (width >= 1024) {
setLength(13);
} else if (width >= 768) {
setLength(11);
} else if (width >= 640) {
setLength(9);
} else {
setLength(7);
}
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
// 차트에 그래프를 그리기위해 최소 데이터, 최대 데이터 및 얼마나 표시될지 설정합니다.
const weightsArray = [...tmp.map((d) => d.weight)];
const minWeight = Math.min(...weightsArray);
const maxWeight = Math.max(...weightsArray);
const ticks = Array.from({ length: maxWeight - minWeight + 3 }, (_, i) => minWeight - 1 + i);
// 화면 크기에 따라 데이터를 잘라줍니다.
const chartData = tmp.slice(-length);
// rechart 라이브러리를 사용해서 체중 변화 추이 그래프를 그려줍니다.
<>
<div className="w-full text-center text-sm text-white/50">나의 체중 변화</div>
<ResponsiveContainer width="100%" height={'99.5%'} debounce={1} minHeight={100}>
{/* <LineChart data={weights?.data!} margin={{ right: 10, left: -15, bottom: 10, top: 10 }}> */}
<LineChart data={chartData} margin={{ right: 0, left: -40, bottom: 10, top: 10 }}>
<XAxis
filter="url(#glow)"
tickLine={false}
axisLine={false}
dataKey="date"
stroke="#ffffff"
opacity={0.5}
tickFormatter={(tick) => format(tick, 'd')}
tick={{ fontSize: 10 }}
padding={{ left: 10, right: 10 }}
interval={'preserveEnd'}
// angle={-25} // 기울기
// textAnchor="end" // 기울기
/>
<YAxis
filter="url(#glow)"
tickLine={false}
stroke="#ffffff"
opacity={0.5}
axisLine={false}
domain={[minWeight, maxWeight]}
// tickFormatter={(tick) => `${tick}kg`}
tick={{ fontSize: 10 }}
ticks={ticks}
/>
{/* <Tooltip formatter={(value) => `${value}kg`} /> */}
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#ffffff" stopOpacity={0.4} />
<stop offset="100%" stopColor="#ffffff" stopOpacity={1} />
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="300%" height="300%">
<feGaussianBlur stdDeviation="4.5" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<Line
filter="url(#glow)"
connectNulls // 안끊기게?
dataKey="weight"
stroke="url(#gradient)"
dot={({ cx, cy, index }) => {
if (index === chartData.length - 1) {
return (
<circle
key={chartData[index].id}
cx={cx}
cy={cy}
r={4}
fill="#12F287"
stroke="#ffffff/40"
strokeWidth={1}
filter="url(#glow)"
/>
);
}
return (
<circle key={chartData[index].id} cx={cx} cy={cy} r={4} fill="gray" stroke="white" strokeWidth={1} />
);
}}
activeDot={{ r: 16 }}
/>
</LineChart>
</ResponsiveContainer>
</>