react
next_js
web

2025-05-25

타임라인 캘린더를 만들었었는데 안쓰게 되어서 아까워서 여기다 기록해놓는다.

기능

시작날짜와 끝나는 날짜가 있는 ‘이벤트’ 데이터들을 가로로 나열된 날짜에 따라 맵핑한 것이다. 오늘 날짜는 빨간 줄로 표시된다

헤드에는 날짜와 달이 표시되어 있다. 달은 sticky 속성을 가진다. 타임라인에서 각각의 ‘이벤트’ 요소에 호버하면 해당 날짜가 색깔로 표시된다. 주말은 평일보다 옅은 색으로 표시된다.

각각 오늘 날짜와 인풋된 날짜로 스크롤해 이동할 수 있는 버튼도 있다.

개요

  • CalendarPanel에선 유저가 캘린더의 위치를 컨트롤할수있는 버튼들(GoToToday, GoToInputday)과 캘린더(CalendarWindow)를 렌더한다. 그리고 calendarRef를 만들어서 자식한테 뿌려서 그 버튼들이 캘린더를 컨트롤할 수 있게 만든다.
  • CalendarWindow는 캘린더의 헤더 부분인 CalendarHead와 몸체 부분인 CalendarBody를 묶어주는 역할
  • CalendarHead는 헤더에 날짜를 맵핑하고 현재 호버된이벤트의 날짜를 zustand로 받아서 표시한다.
  • CalendarBody는 오늘날짜 인디케이터와 ‘이벤트’들을 렌더한다.
  • CalendarEntry는 Body grid 위에 자기 자신을 맞는 위치에 렌더하고, 호버를 listen하고, 클릭 시 가지고 있던 링크로 라우트한다.

CalendarPanel

'use client'

import { PostProps } from "@/app/lib/types";
import { useRef } from "react";
import { GoToInputday, GoToToday } from "./calendar-controll";
import CalendarWindow from "./calendar-window";

export default function CalendarPanel({calendarEntries, filteredEntries }: {calendarEntries: PostProps[]; filteredEntries: PostProps[]; }) {
  const columnWidth = 35;
  const calendarRef = useRef<HTMLDivElement | null>(null);

  return (
    <>
      {/* calendar controll */}
      <div className="flex">
        <GoToToday first_date={new Date(calendarEntries[0].startDate)} columnWidth={columnWidth} calendarRef={calendarRef} />
        <GoToInputday first_date={new Date(calendarEntries[0].startDate)} columnWidth={columnWidth} calendarRef={calendarRef} min={calendarEntries[0].startDate} max={calendarEntries[calendarEntries.length - 1].endDate}/>
      </div>
      
      {/* calendar */}
      <div className="relative flex flex-col pr-4 items-center w-full overflow-hidden">
        <div
          className="overflow-x-auto w-full h-full z-10"
          id="calendar_window"
          ref={calendarRef}
        >
          <CalendarWindow
            calendarEntries={calendarEntries}
            filteredEntries={filteredEntries}
            columnWidth={columnWidth}
            calendarRef={calendarRef}
          />
        </div>
      </div>
    </>
  );
}

유저가 캘린더의 위치를 컨트롤할수있는 버튼들(GoToToday, GoToInputday)과 캘린더(CalendarWindow)를 렌더한다. 그리고 calendarRef를 만들어서 자식한테 뿌려서 그 버튼들이 캘린더를 컨트롤할 수 있게 만든다.

GoToToday, GoToInputday

'use client'

import { useState } from "react";

const today = new Date();
const day = today.getDate();
const month = today.getMonth() + 1;
const year = today.getFullYear();
const format_day = day < 10 ? '0' + day : day;
const format_month = month < 10 ? '0' + month : month;
const format_today = `${year}-${format_month}-${format_day}`;
export function scrollToDay(
  first_to_targetday: number,
  columnWidth: number,
  ref: React.RefObject<HTMLDivElement | null>,
  scroll: boolean = true
) {
  const window = document.getElementById('calendar_window');

  if (ref.current && window?.offsetWidth) {
    const left = window?.offsetWidth * 0.5 - columnWidth / 2;
    const scrollTarget = first_to_targetday * columnWidth - left;

    ref.current.scrollTo({
      left: scrollTarget,
      behavior: scroll ? "smooth" : "instant",
    });
  }
}

scrollToDay는 ref를 사용해서 특정 날짜로 scrollTo를 하는 함수이다.

export function GoToToday({
  first_date,
  columnWidth,
  calendarRef
} : {
  first_date: Date,
  columnWidth: number,
  calendarRef:React.RefObject<HTMLDivElement | null>
}) {
  const first_to_today = Math.floor((+today - +first_date) / (1000 * 60 * 60 * 24));
  // console.log("first_date", first_date)

  return (
    <button
      className="flex px-2 py-1 bg-gray-200 text-sm hover:bg-gray-300 transition-colors"
      onClick={() => {scrollToDay(first_to_today, columnWidth, calendarRef)}}
    >
      Today
    </button>
  )
}
export function GoToInputday({
  first_date,
  columnWidth,
  calendarRef,
  min,
  max
}: {
  first_date: Date,
  columnWidth: number,
  calendarRef:React.RefObject<HTMLDivElement | null>
  min:string,
  max:string
},
) {
  const [inputDay, setInputDay] = useState(format_today);

  const input_in_date = new Date(inputDay);
  const first_to_inputday = Math.floor((+input_in_date - +first_date) / (1000 * 60 * 60 * 24));

  const handleInputDay = (e: React.ChangeEvent<HTMLInputElement>) => {
    const input = e.target.value;
    setInputDay(input);
  }

  return (
    <div className='pl-4 flex gap-2 items-center'>
      <form
        className="flex gap-2 items-start"
      >
        <label htmlFor="inputDay">Select a day:</label>
        <input
          id="inputDay"
          type="date"
          name="inputDay"
          value={inputDay}
          min={min}
          max={max}
          onChange={handleInputDay}
        >
        </input>
      </form>
      <button
        type="submit"
        className="flex px-2 py-1 bg-gray-200 text-sm hover:bg-gray-300 transition-colors"
        onClick={() => {scrollToDay(first_to_inputday, columnWidth, calendarRef)}}
      >
        Input Day
      </button>
    </div>
  )
}

scrollToDay를 이용하는 두 버튼을 만들어준다.

CalendarWindow

'use client'

import { PostProps } from "@/app/lib/types";
import CalendarHead from "./calendar-head";
import CalendarBody from "./calendar-body";

export default function CalendarWindow({calendarEntries, filteredEntries, columnWidth, calendarRef}: {calendarEntries: PostProps[]; filteredEntries: PostProps[]; token?: string; columnWidth: number; calendarRef: React.RefObject<HTMLDivElement | null> }) {
  return (
    <>
      <CalendarHead
        calendarEntries={calendarEntries}
        columnWidth={columnWidth}
      />
      <CalendarBody
        calendarEntries={calendarEntries}
        filteredEntries={filteredEntries}
        columnWidth={columnWidth}
        calendarRef={calendarRef}
      />
    </>
  )
}

이 중간 컴포넌트는 그냥 가독성을 위해 만들어줬다.

CalendarHead

헤드 데이터는 특정한 객체 형태로 만들어져 있어야 한다. 연도 안에 달이 들어있고 달 안에 날짜가 들어있는 방식으로. 그래야 맵핑이 제대로 되고 달 텍스트에다가 sticky도 붙일 수 있다. 헤드 데이터를 만드는 generateCalendarHeadData는 이렇게 생겼다.

/* eslint-disable prefer-const */

export default function generateCalendarHeadData(firstDate: Date, lastDate: Date) {
  let current = firstDate;
  const end= lastDate;

  const calendar = {
    years: new Map(),
  };

  while (current <= end) {
    const year = current.getFullYear();
    const month = current.getMonth();
    const date = current.getDate();

    if (!calendar.years.has(year)) {
      calendar.years.set(year, { months: new Map() });
    }
    if (!calendar.years.get(year).months.has(month)) {
      calendar.years.get(year).months.set(month, { dates: [] });
    }

    calendar.years.get(year).months.get(month).dates.push(date);
    current.setDate(current.getDate() + 1);
  }

  return calendar;
}

그리고 그걸 CalendarHead에서 이렇게 갖다쓴다.

'use client'

import { PostProps } from "@/app/lib/types"
import generateCalendarHeadData from "@/app/lib/utils/generate-calendar-head-data";
import { useHoveredStore } from "@/app/lib/store/useHoveredStore";
import clsx from "clsx";

export default function CalendarHead({calendarEntries, columnWidth}: {calendarEntries: PostProps[], columnWidth: number;}) {
  const first_date = new Date(calendarEntries[0].startDate);
  const last_date = new Date(calendarEntries[calendarEntries.length - 1].endDate);
  const day_count = Math.max(1, Math.round((+last_date - +first_date) / (1000 * 60 * 60 * 24)));
  const entry_count = calendarEntries.length;

  // generate head
  const head_data = generateCalendarHeadData(first_date, last_date);

  const hoveredDate = useHoveredStore((state) => state.hoveredDate);

헤더 만들때 필요한 정보…first_date와 last_date, day_count, entry_count를 만들고(이 데이터를 body에서도 중복해서 만드는데, 나도 최대한 중복을 안하려 했지만 자꾸 이유를 알 수 없는 오동작을 해서 이렇게 매번 계산할수밖에 없었다…), zustand에서 hoveredDate도 가져오고, head_data에 헤드 데이터 저장하고

return (
    <div className="sticky top-0 z-20">
      <div
        className="flex text-sm text-gray-600 overflow-visible"
        style={{
          width: `${(day_count+1) * columnWidth}px`,
        }}
      >
        {head_data &&
        [...head_data.years.entries()].flatMap(([year, yearData]) =>
          yearData
            ? [...yearData.months.entries()].flatMap(([month, monthData]) => (
              <div
                key={`${year}-${month}`}
                id="month"
                className="flex items-center sticky left-0 pl-4 bg-white"
                style={{width: `${monthData.dates.length * columnWidth}px`}}
              >
                {month === 0 ? `${year}년` : null} {month + 1}
              </div>
            )
              )
            : []
        )}
      </div>

헤더의 첫 번째 줄에 달을 렌더하고

<div
	className="grid absolute items-center"
	style={{
	  gridTemplateColumns: `repeat(${day_count+1}, ${columnWidth}px)`,
	  gridTemplateRows: `repeat(1, ${columnWidth}px)`,
	  width: `${(day_count+1) * columnWidth}px`,
	}}
>
	{hoveredDate ? (
	  <div
		className="relative bg-gray-100 mix-blend-multiply w-auto h-8"
		style={{
		  gridRow: `1 / span ${entry_count}`,
		  gridColumnStart: (+new Date(hoveredDate.startDate) - +new Date(calendarEntries[0].startDate)) / (1000 * 60 * 60 * 24)+1,
		  gridColumnEnd: (+new Date(hoveredDate.endDate) - +new Date(calendarEntries[0].startDate)) / (1000 * 60 * 60 * 24)+2
		}}
>
	  </div>
	) : (
	  null
	)}
  </div>

hoveredDate를 헤더 위에 표시하는 거를 렌더하고

	<div
        className="grid text-sm text-gray-600 bg-white"
        style={{
          gridTemplateColumns: `repeat(${day_count+1}, ${columnWidth}px)`,
          gridTemplateRows: `repeat(1, ${columnWidth}px)`,
          width: `${(day_count+1) * columnWidth}px`,
        }}
      >
        {head_data &&
        [...head_data.years.entries()].flatMap(([year, yearData]) =>
          yearData
            ? [...yearData.months.entries()].flatMap(([month, monthData]) =>
                monthData
                  ? [...monthData.dates.values()].map((actualDate) => {
                    const dateObj = new Date(`${year}-${month + 1}-${actualDate}`);
                    const isWeekend = [0, 6].includes(dateObj.getDay()); // 0 = Sunday, 6 = Saturday

                    return (
                      <div
                        key={`${year}-${month}-${actualDate}`}
                        id="date"
                        className={clsx("text-center flex justify-center items-center", {"text-gray-400": isWeekend})}
                      >
                        {actualDate}
                      </div>
                    );
                  })
                : []
              )
            : []
        )}
      </div>
    </div>
  );
}

헤더의 두 번째 줄에 날짜를 렌더한다.

CalenderBody

'use client'

import CalendarEntry from "./calendar-entry";
import { PostProps } from "@/app/lib/types";
import { useCalendarData } from "../../../lib/utils/use-calendar-data";

interface EntryProps {
	start: number,
	end: number,
}
function TodayIndicator({ entryCount, column }: { entryCount: number; column: number}) {
  return (
    <div
      id="today"
      className="relative z-10 border-l border-red-600 left-1/2"
      style={{
        gridRow: `1 / span ${entryCount}`,
        gridColumnStart: column+1,
        gridColumnEnd: column+2,
      }}
    ></div>
  )
}

오늘 날짜에 빨간줄 긋는거. 열을 얼마만큼 차지할지는 entry의 갯수에 따라 정하고, 행을 얼마만큼 차지할지는 column이라는 파라미터로 시작일부터 오늘까지의 거리를 받아서 정한다.

export default function CalendarBody({
  calendarEntries,
  filteredEntries,
  columnWidth,
  calendarRef
} : {
  calendarEntries: PostProps[],
  filteredEntries: PostProps[],
  columnWidth: number,
  calendarRef: React.RefObject<HTMLDivElement | null>
}
) {
  const entry_count = calendarEntries.length;
  
  const { entries, day_count, first_to_today } = useCalendarData(
    calendarEntries,
    filteredEntries,
    columnWidth,
    calendarRef
  );

  return (
    <div
      className={`grid border`}
      style={{
        gridTemplateColumns: `repeat(${day_count}, ${columnWidth}px)`,
        gridTemplateRows: `repeat(${entry_count}, ${columnWidth}px)`,
        width: `${(day_count+1) * columnWidth}px`,
      }}
    >
      <TodayIndicator entryCount={entry_count} column={first_to_today} />

      {entries.map((entry: EntryProps, index) => (
        <CalendarEntry
          key={`${entry.start}-${index}`}
          entryPosition={entry}
          index={index}
          data={filteredEntries[index]}
        />
      ))}
    </div>
  )
}

calendarEntries와 filteredEntries를 따로 받은 이유는 필터링을 할 때 타임라인의 전체길이가 같이 쪼그라들면 안되기 때문이다.

useCalendarData를 써서 타임라인 Body를 만드는데 필요한 정보를 받아서 Body의 grid를 만든다. 그리고 TodayIndicator와 ‘이벤트’들의 실체 CalendarEntry를 그 grid 위에 렌더한다.

useCalendarData는

  • ‘이벤트’들의 startDate와 endDate의 first_date로부터의 거리를 뽑아낸 것인 entries,
  • first_date와 last_date 사이의 거리 즉 타임라인 전체 날짜 day_count,
  • 오늘 날짜의 first_date로부터의 거리 first_to_today 를 돌려준다.
import { useEffect, useMemo } from "react";
import { PostProps } from "@/app/lib/types";
import { scrollToDay } from "@/app/ui/bulletin/old-calendar/calendar-controll";

export function useCalendarData(
  calendarEntries: PostProps[],
  filteredEntries: PostProps[],
  columnWidth: number,
  calendarRef: React.RefObject<HTMLDivElement | null>
) {
  const MS_PER_DAY = 1000 * 60 * 60 * 24;

  // 전체 범위 계산
  const first_date = useMemo(() => new Date(calendarEntries[0].startDate), [calendarEntries]);
  const last_date = useMemo(() => new Date(calendarEntries[calendarEntries.length - 1].endDate), [calendarEntries]);
  const day_count = useMemo(() => (+last_date - +first_date) / MS_PER_DAY, [first_date, last_date]);

  // today 위치 계산
  const today = new Date();
  const first_to_today = useMemo(() => Math.floor((+today - +first_date) / MS_PER_DAY), [first_date]);

  // entry들의 위치 계산
  const entries = useMemo(() => {
    return filteredEntries.map((entry) => ({
      start: Math.floor((+new Date(entry.startDate) - +first_date) / MS_PER_DAY) + 1,
      end: Math.floor((+new Date(entry.endDate) - +first_date) / MS_PER_DAY) + 1,
    }));
  }, [filteredEntries, first_date]);

  useEffect(() => {
    scrollToDay(first_to_today, columnWidth, calendarRef, false);
  }, [first_to_today, columnWidth, calendarRef]);

  return { entries, day_count, first_to_today };
}

CalendarEntry

'use client'

import { PostProps } from "@/app/lib/types";
import { usePathname, useSearchParams } from "next/navigation";
import Link from "next/link";
import clsx from "clsx";
import { useHoveredStore } from "@/app/lib/store/useHoveredStore";
import generateHref from "@/app/lib/utils/generate-href";

interface EntryProps {
  start: number,
  end: number,
}

export default function CalendarEntry({ entryPosition, index, data }: { entryPosition: EntryProps; index: number; data:PostProps; }) {
  const pathname = usePathname().split('/').slice(1, 2).toString();
  const searchParams = useSearchParams();

  const setHoveredDate = useHoveredStore((state) => state.setHoveredDate);

  return (
    <>
      {data.documentId &&
        <Link
          key={`${data.documentId}-${index}`}
          href={generateHref(pathname, searchParams.toString(), data?.documentId)}
          className={clsx(
            pathname === data?.documentId ? "opacity-50 hover:!bg-opacity-100" : "hover:bg-opacity-50",
            "flex items-center h-8 z-10",
            {"bg-[#00ffff]": data.category === "notices"},
            {"bg-[#ffff00]": data.category === "events"},
            {"bg-[#ff00ff]": data.category === "exhibitions"},
            {"bg-[#eeeeee]": data.category === "kookmins"},
          )}
          style={{
            gridRowStart: index+1, // entryPosition.row
            gridRowEnd: index+2,
            gridColumnStart: entryPosition.start,
            gridColumnEnd: entryPosition.end+1,
          }}
          onMouseEnter={() => setHoveredDate({ startDate: data.startDate, endDate: data.endDate })}
          onMouseLeave={() => setHoveredDate(null)}
        >
          <p className="text-sm text-nowrap">{data.name}
            {/* <span className="text-gray-800 opacity-50">{data.startDate}-{data.endDate}, {entryPosition.start}, {entryPosition.end+1}, {entryPosition.end+1 - entryPosition.start}칸</span> */}
          </p>
        </Link>
      }
    </>
  );
}