Skip to content

Dashboard

butterbeetle edited this page Aug 21, 2024 · 4 revisions

📊 대시보드

대시보드는 건강과 운동 여정의 전반적인 개요를 제공합니다.
참여한 챌린지와 운동, 식단, 체중 변화를 모니터링할 수 있습니다.

⚙ 기능

  1. 레벨 표시

    • 현재 레벨과 다음 레벨까지의 진행 상황을 확인할 수 있습니다.
  2. 내가 참여한 챌린지 확인

    • 참여한 챌린지 목록과 각 챌린지의 상태를 확인할 수 있습니다.
  3. 운동 확인

    • 최근의 운동 기록을 요약하여 보여줍니다.
  4. 식단 확인

    • 최근 섭취한 식단을 요약하여 보여줍니다.
  5. 체중 변화 추이 그래프

    • 시간에 따른 체중 변화를 시각적으로 나타낸 그래프입니다.

💻 Code

대시보드
  // 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>
</>

📷 View

대시보드
그래프 반응형
Clone this wiki locally