image

달력 (히트맵) 만들기 (2)

태그
ReactJavascript
상세설명히트맵으로 달력 만들고 해당 날에 포스팅된 글 있으면 표시하기
작성일자2024.06.08

달력 (히트맵) 만들기 (1) 이어서…

이전 글에서는 달력을 만들었다면 이번 글에서는 날짜에 해당하는 포스트 수와 Home에서 이번 달 만이 아닌 다른 달과 년도를 선택할 수 있는 모달 창과 월 별 총 포스팅 수, 달력을 만든<PostHeatMap /> 에 넘겨줄 props 를 구한다.

날짜에 해당하는 포스트 수

달력을 만들기 전에 날짜마다 포스팅 된 글 수를 구한다.

  • map() 함수를 사용하여 각 포스트의 created_time을 추출하고, 이를 JavaScript의 Date 객체로 변환하여 해당 날짜를 한국 표준시(UTC+9)로 변환하고, 날짜 정보에서 시간 부분을 제외한 후 toISOString()을 통해 ISO 형식의 문자열로 변환해 split("T")[0]을 사용하여 날짜 부분 만을 추출하여 korDate에 저장한다.
  • createPostCount는 날짜 별 포스트 개수를 저장하는 객체로 createPost 배열을 순회하면서 각 날짜에 대한 포스트 개수를 계산한다.
  • createPostCount[x] = (createPostCount[x] || 0) + 1 설명

  • createPostCount[x] : 날짜(x)를 키로 하는 createPostCount 객체에서 해당 날짜의 값에 접근합니다. 만약 해당 키가 존재하지 않으면 undefined가 반환된다.
  • (createPostCount|| 0) : 순회시 해당 날짜의 값을 가져오되, || 연산자를 사용하여 만약 값이 존재하지 않으면 ( = 처음 마주한 값 ) 0으로 대체합니다. 만약 createPostCount[x]가 값이 존재하면 그 값을 그대로 사용한다.
  • // postHeatMap.tsx
    const createPost = blogs.results.map((x: { created_time: string }) => {
        const create = new Date(x.created_time);
        const korDate = new Date(
          create.getTime() - create.getTimezoneOffset() * 60000
        )
          .toISOString()
          .split("T")[0];
        return korDate;
      });
    
    const createPostCount: PostCountType = {};
    
    createPost.forEach((x: string | number) => {
      createPostCount[x] = (createPostCount[x] || 0) + 1;
    });

    년도, 월 선택 모달 창

    먼저 표기할 month , year를 배열에 담는다.

    month는 영어로도 표기하고 싶어 배열 안 객체 [ { monthEng: "Jan", monthNum: “01”}, … ]로 구성했다.

    const engMonthName = [
        { monthEng: DEFINE.MONTHS.JAN.ENG, monthNum: DEFINE.MONTHS.JAN.NUM },
         ...
        { monthEng: DEFINE.MONTHS.DEC.ENG, monthNum: DEFINE.MONTHS.DEC.NUM },
      ];
    
    const years = [2023, 2024];

    useState를 활용해 monthList에 month 영문과 숫자를 저장하고 yearList에는 년도, showMonthModal는 boolean으로 모달 창 show 여부를 저장한다.

    selectMonth() , toggleMonthList() 함수를 만들어 클릭 이벤트 발생 시 값이 변경 되게 했다.

    ⭐ 포스팅을 9월 말 부터 시작하여 2023.09 부터 선택 가능하게 하기 위해 2023년 선택 시 9월이전 달은 미 표기 했다.

    const today = new Date();
    const year = today.getFullYear();
    const engMonth = engMonthName[today.getMonth()].monthEng;
    const numMonth = engMonthName[today.getMonth()].monthNum;
    
    const [monthList, setMonthList] = useState({ engMonth, numMonth });
    const [yearList, setYearList] = useState(year);
    
    // modal 
    const [showMonthModal, setShowMonthModal] = useState(false);
    const selectMonth = (engMonth: string, numMonth: string) => {
        setMonthList({ engMonth, numMonth });
    };
    
    const toggleMonthList = () => {
     setShowMonthModal((prev) => !prev);
    };
    
    //
    <div className="text-base cursor-pointer relative">
        <span
          onClick={toggleMonthList}
          ref={dropMonthMenuBtnRef}
        >
          {yearList}. {monthList.engMonth}
        </span>
        {showMonthModal && (
            <div ref={dropMonthMenuRef}>
               <ul>
                   {years.map((year) => (
                      <li
                        key={year}
                        onClick={() => {
                          if (matchExceptMonth(monthList.engMonth)) {
                              if (year !== 2023) {
                                  setYearList(year);
                              }
                            } else {
                                  setYearList(year);
                            }
                         }}
                         className={cls(
                            yearList === year ? "month-selected" : "",
                            matchExceptMonth(monthList.engMonth) &&
                               year === 2023
                                 ? "month-disabled"
                                 : "month-noneDisabled",
                                 "month-base"
                            )}
                          >
                            {year}
                           </li>
                        ))}
                        </ul>
                        <ul className="h-48 overflow-y-auto scrollbar-none">
                            {yearList === 2023
                              ? engMonthName.slice(8).map((mon) => (
                                  <li
                                    key={mon.monthEng}
                                    onClick={() => {
                                      if (
                                        !(
                                          yearList === 2023 &&
                                          matchExceptMonth(mon.monthEng)
                                        )
                                      ) {
                                        selectMonth(mon.monthEng, mon.monthNum);
                                      }
                                    }}
                                    className={cls(
                                      monthList.engMonth === mon.monthEng
                                        ? "month-selected"
                                        : "month-noneDisabled",
                                      "month-base"
                                    )}
                                  >
                                    {mon.monthEng}
                                  </li>
                                ))
                              : engMonthName.map((mon) => (
                                  <li
                                    key={mon.monthEng}
                                    onClick={() => {
                                      if (
                                        !(
                                          yearList === 2023 &&
                                          matchExceptMonth(mon.monthEng)
                                        )
                                      ) {
                                        selectMonth(mon.monthEng, mon.monthNum);
                                      }
                                    }}
                                    className={cls(
                                      monthList.engMonth === mon.monthEng
                                        ? "month-selected"
                                        : "month-noneDisabled",
                                      "month-base"
                                    )}
                                >
                                {mon.monthEng}
                               </li>
                         ))}
                  </ul>
            </div>
         )}
    </div>

    모달 오픈 시 body scroll 방지

    useEffect를 활용해 showMonthModal변경을 감지해 모달 오픈 시 body scroll 방지했다.

    관련 글 : https://min-sun.vercel.app/blog/fa29c907-9d40-46ef-8fee-1b40bd112fab

    const preventScroll = () => {
        const currentScrollY = window.scrollY;
        document.body.style.position = "fixed";
        document.body.style.width = "100%";
        document.body.style.top = `-${currentScrollY}px`;
        document.body.style.overflowY = "scroll";
    
        return currentScrollY;
      };
    
      const allowScroll = (prevScrollY: number) => {
        document.body.style.position = "";
        document.body.style.width = "";
        document.body.style.top = "";
        document.body.style.overflowY = "";
        window.scrollTo(0, prevScrollY);
      };
    
      useEffect(() => {
        if (showMonthModal) {
          const prevScrollY = preventScroll();
          return () => {
            allowScroll(prevScrollY);
          };
        }
     }, [showMonthModal]);

    모달 외부 클릭 시 닫기

    pageState 처럼 모달 외부 클릭 시 닫힐 수 있게 적용했다.

    관련 글 : https://min-sun.vercel.app/blog/f837f57d-3a2e-47f6-b1e3-be23cc132876

    const dropMonthMenuBtnRef = useRef<HTMLDivElement | null>(null);
    const dropMonthMenuRef = useRef<HTMLDivElement | null>(null);
    useEffect(() => {
        const handleClickOutsideClose = (e: MouseEvent) => {
          if (
            showMonthModal &&
            !dropMonthMenuRef.current?.contains(e.target as Node) &&
            !dropMonthMenuBtnRef.current?.contains(e.target as Node)
          )
            setShowMonthModal(false);
        };
        document.addEventListener("click", handleClickOutsideClose);
    
        return () => document.removeEventListener("click", handleClickOutsideClose);
      }, [showMonthModal]);
    
      const matchExceptMonth = (select: string) => {
        return exceptMonth.some((exceptMonth) => exceptMonth.monthEng === select);
    };
    
    //
    <div className="text-base cursor-pointer relative">
        <span
          onClick={toggleMonthList}
          ref={dropMonthMenuBtnRef}
        >
          {yearList}. {monthList.engMonth}
        </span>
        {showMonthModal && (
          <div ref={dropMonthMenuRef} >
            // .... 선택하는 년도, 월
          </div>
        )}
    </div>

    월 별 포스팅 수

    createPost()

  • 각 포스트의 created_time 속성을 추출하여 create 변수에 저장합니다.
  • 해당 시간을 현재 시스템의 타임존과 UTC와의 차이를 고려하여 한국 표준시(UTC+9)로 변환하여 날짜 부분만 추출한다.
  • mathMonth()

  • createPost 배열을 사용하여, 선택된 년도( yearList )와 월(monthList.numMonth)에 해당하는 포스트들을 필터링 해 x.slice(0, 7)를 통해 각 날짜의 연도와 월 부분과 일치하는 포스트의 작성 날짜만이 mathMonth 배열에 저장한다.
  • const createPost = blogs.results.map((x: { created_time: string }) => {
        const create = new Date(x.created_time);
        const korDate = new Date(
          create.getTime() - create.getTimezoneOffset() * 60000
        )
          .toISOString()
          .split("T")[0];
    
        return korDate;
      });
    
    const mathMonth = createPost.filter(
        (x: string) => x.slice(0, 7) === yearList + "-" + monthList.numMonth
     );
    
    
    // 월별 포스트 수
    <span>
      {mathMonth.length > 0 ? mathMonth.length : 0} posts in{" "}
      {monthList.engMonth}
    </span>

    <PostHeatMap />

    PostHeatMap 컴포넌트에 블로그 데이터와 변경되는 년도, 월 값을 넘겨 준다.

    <div className="mt-2 h-full w-full">
       <PostHeatMap
         blogs={blogs}
         year={yearList}
         month={monthList.numMonth}
       />
    </div>

    전체코드

    import { DATABASE_ID_BLOG, TOKEN } from "libs/config";
    import { useEffect, useRef, useState } from "react";
    import dynamic from "next/dynamic";
    import DEFINE from "@/constant/Global";
    import { BlogistObject, ListResults } from "@/InterfaceGather";
    const PostHeatMap = dynamic(
      () => import("@/components/ScreenElement/postHeatMap"),
      {
        ssr: false,
      }
    );
    
    export default function Home({ blogs }: BlogistObject) {
      const today = new Date();
      const year = today.getFullYear();
      const engMonthName = [
        { monthEng: DEFINE.MONTHS.JAN.ENG, monthNum: DEFINE.MONTHS.JAN.NUM },
         ...
        { monthEng: DEFINE.MONTHS.DEC.ENG, monthNum: DEFINE.MONTHS.DEC.NUM },
      ];
    
      const years = [2023, 2024];
    
      const engMonth = engMonthName[today.getMonth()].monthEng;
      const numMonth = engMonthName[today.getMonth()].monthNum;
    
      const createPost = blogs.results.map((x: { created_time: string }) => {
        const create = new Date(x.created_time);
        const korDate = new Date(
          create.getTime() - create.getTimezoneOffset() * 60000
        )
          .toISOString()
          .split("T")[0];
    
        return korDate;
      });
    
      const [monthList, setMonthList] = useState({ engMonth, numMonth });
      const [yearList, setYearList] = useState(year);
    
      const [showMonthModal, setShowMonthModal] = useState(false);
      const selectMonth = (engMonth: string, numMonth: string) => {
        setMonthList({ engMonth, numMonth });
      };
    
      const toggleMonthList = () => {
        setShowMonthModal((prev) => !prev);
      };
    
      const mathMonth = createPost.filter(
        (x: string) => x.slice(0, 7) === yearList + "-" + monthList.numMonth
      );
    
      const exceptMonth = engMonthName.slice(0, 8).map((mon) => mon);
    
      const preventScroll = () => {
        const currentScrollY = window.scrollY;
        document.body.style.position = "fixed";
        document.body.style.width = "100%";
        document.body.style.top = `-${currentScrollY}px`;
        document.body.style.overflowY = "scroll";
    
        return currentScrollY;
      };
    
      const allowScroll = (prevScrollY: number) => {
        document.body.style.position = "";
        document.body.style.width = "";
        document.body.style.top = "";
        document.body.style.overflowY = "";
        window.scrollTo(0, prevScrollY);
      };
    
      useEffect(() => {
        if (showMonthModal) {
          const prevScrollY = preventScroll();
          return () => {
            allowScroll(prevScrollY);
          };
        }
      }, [showMonthModal]);
    
      const dropMonthMenuBtnRef = useRef<HTMLDivElement | null>(null);
      const dropMonthMenuRef = useRef<HTMLDivElement | null>(null);
      useEffect(() => {
        const handleClickOutsideClose = (e: MouseEvent) => {
          if (
            showMonthModal &&
            !dropMonthMenuRef.current?.contains(e.target as Node) &&
            !dropMonthMenuBtnRef.current?.contains(e.target as Node)
          )
            setShowMonthModal(false);
        };
        document.addEventListener("click", handleClickOutsideClose);
    
        return () => document.removeEventListener("click", handleClickOutsideClose);
      }, [showMonthModal]);
    
      const matchExceptMonth = (select: string) => {
        return exceptMonth.some((exceptMonth) => exceptMonth.monthEng === select);
      };
    
      return (
         <>
          ...
           <div>
               <div >
                   <span>
                     {mathMonth.length > 0 ? mathMonth.length : 0} posts in{" "}
                     {monthList.engMonth}
                   </span>
                   <div>
                      <span
                        onClick={toggleMonthList}
                        ref={dropMonthMenuBtnRef}
                      >
                        {yearList}. {monthList.engMonth}
                      </span>
                      {showMonthModal && (
                        <div ref={dropMonthMenuRef}>
                          <ul>
                            {years.map((year) => (
                              <li
                                key={year}
                                onClick={() => {
                                  if (matchExceptMonth(monthList.engMonth)) {
                                    if (year !== 2023) {
                                      setYearList(year);
                                    }
                                  } else {
                                    setYearList(year);
                                  }
                                }}
                                className={cls(
                                  yearList === year ? "month-selected" : "",
                                  matchExceptMonth(monthList.engMonth) &&
                                    year === 2023
                                    ? "month-disabled"
                                    : "month-noneDisabled",
                                  "month-base"
                                )}
                              >
                                {year}
                              </li>
                            ))}
                          </ul>
                          <ul className="h-48 overflow-y-auto scrollbar-none">
                            {yearList === 2023
                              ? engMonthName.slice(8).map((mon) => (
                                  <li
                                    key={mon.monthEng}
                                    onClick={() => {
                                      if (
                                        !(
                                          yearList === 2023 &&
                                          matchExceptMonth(mon.monthEng)
                                        )
                                      ) {
                                        selectMonth(mon.monthEng, mon.monthNum);
                                      }
                                    }}
                                    className={cls(
                                      monthList.engMonth === mon.monthEng
                                        ? "month-selected"
                                        : "month-noneDisabled",
                                      "month-base"
                                    )}
                                  >
                                    {mon.monthEng}
                                  </li>
                                ))
                              : engMonthName.map((mon) => (
                                  <li
                                    key={mon.monthEng}
                                    onClick={() => {
                                      if (
                                        !(
                                          yearList === 2023 &&
                                          matchExceptMonth(mon.monthEng)
                                        )
                                      ) {
                                        selectMonth(mon.monthEng, mon.monthNum);
                                      }
                                    }}
                                    className={cls(
                                      monthList.engMonth === mon.monthEng
                                        ? "month-selected"
                                        : "month-noneDisabled",
                                      "month-base"
                                    )}
                                  >
                                    {mon.monthEng}
                                  </li>
                                ))}
                          </ul>
                        </div>
                      )}
                    </div>
                    <span>{blogs.results.length} total posts</span>
                 </div>
                 <div className="mt-2 h-full w-full">
                   <PostHeatMap
                     blogs={blogs}
                     year={yearList}
                     month={monthList.numMonth}
                  />
               </div>
           </div>
          ...
         </>
      );
    }

    최종 결과