프로젝트/웹페이지

웹 개발 1도 모르는 사람이 웹페이지 만들기 프로젝트 10일차

쪼르뚜 2025. 1. 20. 21:35
728x90
반응형

썸네일 - 뤼튼 생성 AI 이미지

 

 

오늘은 깃허브 잔디처럼 월별 잔디를 만들어보겠습니다. 근데 알록달록한...

 

미리 보기

 

👩‍💻 코드 리팩토링

 

오늘 작업하기에 앞서 점점 거대해지는 page.js와 main-client.js 코드를 리팩토링했습니다.

우선 page.js에서 데이터 요청을 분리하여 가독성과 재사용성을 높였습니다.

// 노션 멤버 DB 가져오기
const fetchMemberData = async () => {
    try {
      const response = await notion.databases.query({ database_id: memberDatabaseId });
      return response.results.map((page) => ({
        memberId: page.id,
        memberName: page.properties["이름"]?.title[0]?.text?.content || "Unknown",
        goal: page.properties["목표"]?.select?.name || "Unknown",
        position: page.properties["직책"]?.select?.name || "Unknown",
        iconUrl: page.icon?.external?.url || null,
      }));
    } catch (error) {
      console.error("Failed to fetch member data:", error);
      return [];
    }
};

// 노션 활동 DB 가져오기
const fetchActivityData = async () => {
    let activities = [];
    let hasMore = true;
    let nextCursor = null;

    try {
      while (hasMore) {
        const response = await notion.databases.query({
          database_id: activityDatabaseId,
          start_cursor: nextCursor || undefined,
        });

        activities = [
          ...activities,
          ...response.results.map((page) => ({
            memberId: page.properties["멤버 DB"]?.relation[0]?.id || null,
            time: page.properties["시간"]?.number || 0,
            state: page.properties["상태"]?.select?.name || "좋음",
            rollupDate: page.properties["날짜"]?.rollup?.array[0]?.date?.start || null,
          })),
        ];

        hasMore = response.has_more;
        nextCursor = response.next_cursor;
      }
    } catch (error) {
      console.error("Failed to fetch activity data:", error);
    }

    return activities;
};

데이터 가공 부분도 따로 분리하였고, 로직을 단순화시켰습니다.

const processData = (members, activities) => {
    const memberMap = Object.fromEntries(members.map((m) => [m.memberId, m]));
    const activitySummary = {};

    activities.forEach(({ memberId, time, state, rollupDate }) => {
      if (!rollupDate || !memberMap[memberId]) return;

      const date = new Date(rollupDate);
      const year = date.getFullYear();
      const month = date.getMonth() + 1;
      const day = date.getDate();
      const monthKey = `${year}년 ${String(month).padStart(2, "0")}월`;

      if (!activitySummary[memberId]) {
        activitySummary[memberId] = { activityByMonth: {}, totalTime: 0 };
      }

      const memberActivity = activitySummary[memberId];
      if (!memberActivity.activityByMonth[monthKey]) {
        memberActivity.activityByMonth[monthKey] = {
          totalTime: 0,
          count: 0,
          stateCounts: { 좋음: 0, 보통: 0, 나쁨: 0 },
          activityByDate: {},
        };
      }

      const monthActivity = memberActivity.activityByMonth[monthKey];
      monthActivity.totalTime += time;
      monthActivity.count += 1;
      monthActivity.stateCounts[state] = (monthActivity.stateCounts[state] || 0) + 1;
      monthActivity.activityByDate[day] = {
        time: (monthActivity.activityByDate[day]?.time || 0) + time,
        state,
      };

      memberActivity.totalTime += time;
    });

    return members.map((member) => ({
      ...member,
      ...activitySummary[member.memberId],
    }));
  };

  const memberData = await fetchMemberData();
  const activityData = await fetchActivityData();
  const processedData = processData(memberData, activityData);

main-client.js도 함수와 컴포넌트를 분리했습니다.

// 현재 년도와 월 구하는 함수
function getCurrentMonth() {
    const today = new Date();
    const year = today.getFullYear();
    const month = String(today.getMonth() + 1).padStart(2, "0");
    return `${year}년 ${month}월`;
}

// 멤버 카드 그리는 컴포터는 분리
function MemberCard({ memberName, iconUrl, monthData, selectedMonth }) {
    const { totalTime, count, stateCounts } = monthData;
    const activityByDate = monthData.activityByDate;

    return (
        <div key={memberName} className="p-2 lg:w-1/3 md:w-1/2 w-full">
            <div className="h-full flex flex-col items-center border-gray-200 border p-4 rounded-lg">
                {iconUrl && (
                    <img
                        src={iconUrl}
                        alt={`${memberName}의 아이콘`}
                        className="w-16 h-16 mb-4 rounded-full"
                    />
                )}
                <h2 className="text-gray-900 title-font font-medium mb-2">{memberName}</h2>
                <p className="text-gray-500 mb-2">
                    {count}일 동안 {Math.floor(totalTime)}시간
                </p>
                <div className="flex w-full space-x-4 items-start">
                    <div className="w-1/2 h-40">
                        <PieChart stateCounts={stateCounts} />
                    </div>
                </div>
            </div>
        </div>
    );
}

MainClient 코드가 간소화 됐습니다.

추후 다른 컴포넌트들도 수정할 때 분리시켜야겠습니다.

export default function MainClient({ memberData }) {
    const [data, setData] = useState(null);
    const [selectedMonth, setSelectedMonth] = useState(getCurrentMonth());

    useEffect(() => {
        if (memberData) {
            setData(memberData);
        }
    }, [memberData]);

    if (!data) {
        return <div>Loading...</div>;
    }

    const uniqueMonths = Array.from(
        new Set(
            memberData.flatMap((member) => Object.keys(member.activityByMonth))
        )
    ).sort();

    return (
        <section className="text-gray-500 body-font">
            <div className="container px-5 py-24 mx-auto">
                <div className="flex flex-col text-center w-full mb-10">
                    <h1 className="sm:text-3xl text-2xl font-medium title-font mb-4 text-gray-900">
                        짭알못 활동 기록
                    </h1>
                    <p className="lg:w-2/3 mx-auto leading-relaxed text-base">
                        과거에 '금요일을 알차게 보내는 건 못 참지'라는 금요일마다 자기계발을 하는 모임이 있었습니다.
                        저희는 그 모임과 별개로 매주 목요일마다 자기계발 모임을 하였습니다.
                        어느 날 누군가 '짭알못이다!'라고 외친 이후로 그렇게 불리게 되었습니다.
                        하지만 지금은 우리가 찐 ㅋ😎
                    </p>
                </div>
                <div className="flex justify-end items-center w-full mb-6">
                    <select
                        className="border border-gray-300 rounded-md px-4 py-2 bg-white"
                        value={selectedMonth}
                        onChange={(e) => setSelectedMonth(e.target.value)}
                    >
                        {uniqueMonths.map((month) => (
                            <option key={month} value={month}>
                                {month}
                            </option>
                        ))}
                    </select>
                </div>
                <div className="flex flex-wrap -m-2">
                    {memberData
                        .map(({ memberName, iconUrl, activityByMonth }) => {
                            const monthData = activityByMonth[selectedMonth];
                            if (!monthData) return null;
                            return (
                                <MemberCard
                                    key={memberName}
                                    memberName={memberName}
                                    iconUrl={iconUrl}
                                    monthData={monthData}
                                    selectedMonth={selectedMonth}
                                />
                            );
                        })}
                </div>
            </div>
        </section>
    );
}

 

🌱 월 별 잔디 만들기

 

월 별 잔디를 만들기 위해 필요한 정보들을 구합니다.

 

  • selectedMonth : 현재 선택된 월
  • activityByDate : object로 key는 일, value에는 시간과 상태가 들어있음
  • totalDays : 선택된 월의 마지막 날짜
  • firstDay : 해당 월 1일의 요일 ( 0은 일요일, 1은 월요일 ... )
  • dayArray : 1일부터 마지막 달까지 배열 생성
function MonthGrid({ selectedMonth, activityByDate }) {
    const [days, setDays] = useState([]);
    const [startDay, setStartDay] = useState(0);

    useEffect(() => {
        const [year, month] = selectedMonth.split("년 ").map((str) => str.replace("월", "").trim());
        const totalDays = new Date(year, month, 0).getDate();
        const firstDay = new Date(year, month - 1, 1).getDay();
        const dayArray = Array.from({ length: totalDays }, (_, i) => i + 1);
        
        setDays(dayArray);
        setStartDay(firstDay);
    }, [selectedMonth]);
}

시작 요일 이전에는 빈칸을 채우고, 해당 일의 상태에 맞게 이모지를 표시합니다.

return (
    <div className="grid grid-cols-7 gap-1 text-center">
        {Array.from({ length: startDay }, (_, i) => (
            <div key={`empty-${i}`} className="w-6 h-6 flex items-center justify-center"></div>
        ))}
        {days.map((day) => {
            const activity = activityByDate[day]?.state;

            let emoji;

            switch (activity) {
                case "좋음":
                    emoji = "🟩";
                    break;
                case "보통":
                    emoji = "🟨";
                    break;
                case "나쁨":
                    emoji = "🟥";
                    break;
                default:
                    emoji = "⬜";
            }

            return (
                <div key={day} className="w-6 h-6 flex items-center justify-center">
                    {emoji}
                </div>
            );
        })}
    </div>
);

멤버 카드를 그리는 함수에서 차트 옆에 위치시켜 줍니다.

<div className="flex w-full space-x-4 items-start">
    <div className="w-1/2 h-40">
        <PieChart stateCounts={stateCounts} />
    </div>
    <div className="w-1/2">
        <MonthGrid
            selectedMonth={selectedMonth}
            activityByDate={activityByDate}
        />
    </div>
</div>

 

미리 보기에서처럼 월 별 잔디를 확인할 수 있습니다🌱

 

https://github.com/JoHyeonJi0408/Study-Introduction-Website/pull/4/commits/47e63c5999e2ccbfd6ec34ea01b73688451dbdd5

 

월 별 잔디 추가 by JoHyeonJi0408 · Pull Request #4 · JoHyeonJi0408/Study-Introduction-Website

 

github.com

 

728x90
반응형