React

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

minsun309 2024. 9. 12. 09:20

달력 (히트맵) 만들기 (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);
};

return (
  <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 방지했다.

관련 글

 

모달 오픈 시 body 스크롤 방지 (이전 스크롤 위치 기억)

이번에는 이전 스크롤 위치를 기억하면서 모달 창이 보이면 body 스크롤을 방지하는 방법에 대해 알아 보았다. 모달 창을 여는 index.tsx 로 close를 넘긴다.//index.tsxexport default function Home() { const [moda

minsun309.tistory.com

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 처럼 모달 외부 클릭 시 닫힐 수 있게 적용했다.

관련 글

 

react 모달 밖 클릭 시 닫기

일반적으로 모달 닫기 버튼 외에 모달 영역 밖을 클릭 시 닫을 수 있어야 된다.  모달 열고 닫기 기본형useState를 활용해 클릭하는 버튼을 이전 값에 반대되는 값이 들어오게 해서 true면 모달 창

minsun309.tistory.com

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>
      ...
     </>
  );
}

 

 

최종 결과