본문 바로가기

프로젝트 개발일지/토이 프로젝트

달력에 부가 기능 추가하기-2

저번 글에 이어 부가 기능들을 몇 가지 더 추가해보도록 하겠다.

 

일정이 많아지는 경우에는 스크롤할 수 있도록 css를 변경해보자!

변경한 코드는 다음과 같다.

// App.css
* {
    text-align: center;
    margin-left: auto;
    margin-right: auto;
}

/* HEADER */
.Header {
    display: flex;
    justify-content: space-between;
    background-color: rgb(240, 235, 235);
    border-radius: 20px;
    border: 1.5px solid rgb(72, 68, 68);
    max-width: 600px;
}

.left-col {
    display: flex;
    margin: 10px;
}

.current-month {
    font-size: 24px;
    font-weight: 600;
    color: rgb(72, 68, 68);
}

.current-year {
    align-self: flex-end;
    margin-left: 5px;
    font-size: 16px;
    font-weight: 400;
    color: rgb(72, 68, 68);
}

.right-col {
    margin: 10px;
    align-self: center;
}

.month-button {
    border-radius: 10px;
    border: 1.5px solid rgb(72, 68, 68);
    margin-left: 10px;
    font-size: 24px;
    cursor: pointer;
}

/* DAYS */
.days-list {
    display: flex;
    max-width: 600px;
    padding: 0;
}

.days {
    list-style: none;
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 10px;
    width: 70px;
    background-color: rgb(240, 235, 235);
    margin-left: auto;
    margin-right: auto;
}

.days_0 {
    margin-left: 0;
}

.days_6 {
    margin-right: 0;
}

/* BODY */
.Body {
    position: relative;
}

.row {
    max-width: 600px;
    display: flex;
    margin-bottom: 10px;
}

.day-disabled,
.day-valid,
.day-selected {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 10px;
    width: 70px;
    height: 70px;
    background-color: white;
    cursor: pointer;
}

.day_0 {
    margin-left: 0;
}

.day_6 {
    margin-right: 0;
}

.day-disabled {
    border: 1.5px solid rgb(208, 203, 203);
}

.day-selected {
    background-color: rgb(213, 205, 205);
}

.text-not-valid,
.text-valid {
    display: flex;
    padding: 10px;
}

.text-not-valid {
    color: rgb(208, 203, 203);
}

.text-schedule {
    border: 1.5px solid  rgb(72, 68, 68);
    border-radius: 50%;
    background-color: rgb(213, 205, 205);
    padding: 0;
    margin-left: 7px;
    margin-top: 7px;
    width: 25px;
    height: 25px;
    display: flex;
    justify-content: center;
}

/* SCHEDULE */
.Schedule {
    position: absolute;
    top: 50px;
    left: calc(50% - 150px);
    width: 300px;
    height: 300px;
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 20px;
    background-color: white;
    opacity: 90%;
}

.Schedule h1 {
    margin-top: 40px;
}

.modal-close-button {
    border: transparent;
    background-color: white;
    font-size: 20px;
    font-weight: 700;
    cursor: pointer;
    position: absolute;
    top: 20px;
    right: 20px;
}

.new-schedule-wrapper {
    display: flex;
}

.new-schedule-button {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 8px;
    height: 30px;
    margin: 0px;
    margin-right: auto;
    cursor: pointer;
    font-weight: 700;
}

.new-schedule-input {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 8px;
    height: 27px;
    padding: 0;
    margin-right: 5px;
    margin-left: auto;
}

.schedule-item-wrapper {
    width: 265px;
    height: 100px;
    display: flex;
    flex-direction: column;
    padding: 0;
    padding-top: 10px;
    overflow: auto;
}

.schedule-item-wrapper::-webkit-scrollbar {
    border: 1.5px solid  rgb(72, 68, 68);
    background-color: rgb(229, 222, 222);
    border-radius: 10px;
}

.schedule-item-wrapper::-webkit-scrollbar-thumb {
    border: 1.5px solid  rgb(72, 68, 68);
    background-color: rgb(95, 92, 92);
    border-radius: 10px;
}

.schedule-item {
    list-style: none;
    font-weight: 550;
    margin-bottom: 10px;
}

위와 같이 일정이 많이 있는 경우 스크롤을 통해 여러 일정들을 볼 수 있도록 하였다.

일정이 많지 않은 경우는 스크롤이 보이지 않게 하였다.

 

이는 overflow: auto 속성을 통해서 구현 가능하다.

스크롤바는 디자인을 조금 변경시켜 주었다.

 

일정이 있는 날달력에 표시하자!

이번 기능이 가장 복잡했다.

기능 구현많은 에러가 발생해서 잔뜩 시간을 썼던 것 같다.

 

다음 코드는 일정이 있는 날들을 로컬 스토리지로부터 불러와 달력에 표시할 수 있도록 설정한 코드이다.

 

또한, 일정 추가바로 달력에 표시까지 되도록 하였다.

// Body.js
/* eslint-disable no-loop-func */
import { endOfMonth, endOfWeek, format, isSameMonth, isSameDay, startOfMonth, startOfWeek, addDays, parse } from "date-fns";
import Schedule from "./Schedule";

import { useEffect, useState } from "react";

const Body = ({ currentMonth, selectedDate }) => {
    const monthStart = startOfMonth(currentMonth);
    const monthEnd = endOfMonth(monthStart);
    const startDate = startOfWeek(monthStart);
    const endDate = endOfWeek(monthEnd);

    const rows = [];
    let days = [];
    let day = startDate;
    let formattedDate = '';

    const [isScheduleVisible, setIsScheduleVisible] = useState(0);
    const [hasSchedule, setHasSchedule] = useState([]);

    const handleScheduleVisible = (e) => {
        setIsScheduleVisible(e.currentTarget.getAttribute('value'));
    }

    useEffect(() => {
        const scheduleData = JSON.parse(localStorage.getItem('has-schedule'));

        if (scheduleData?.length) {
            setHasSchedule(scheduleData);
        }
    }, []);

    while (day <= endDate) {
        for (let i = 0; i < 7; i++) {
            formattedDate = format(day, 'd');

            days.push(
                <div
                    className={`day-${
                        !isSameMonth(day, monthStart)
                        ? 'disabled'
                        : isSameDay(day, selectedDate)
                        ? 'selected'
                        : format(currentMonth, 'M') !== format(day, 'M')
                        ? 'not-valid'
                        : 'valid'
                    } day_${i}`}
                    key={day}
                    value={format(day, 'M/d')}
                    onClick={handleScheduleVisible}
                >
                    <span
                        className={[
                            format(currentMonth, 'M') !== format(day, 'M') 
                            ? 'text-not-valid' 
                            : 'text-valid',
                            Array.isArray(hasSchedule) && hasSchedule?.find((scheduleItem) => scheduleItem?.month === parseInt(format(day, 'M')) && scheduleItem?.day === parseInt(format(day, 'd')))
                            ? 'text-schedule-on'
                            : 'text-schedule-off'
                        ].join(' ')}
                    >
                        {formattedDate}
                    </span>
                </div>
            )

            day = addDays(day, 1);
        }
        
        rows.push(
            <div className='row' key={day}>
                {days}
            </div>
        )
        days = [];
    }

    return (
        <div className='Body'>
            {rows}
            { isScheduleVisible !== 0 ? (
                <Schedule
                    isScheduleVisible={isScheduleVisible} 
                    setIsScheduleVisible={setIsScheduleVisible}
                    setHasSchedule={setHasSchedule}
                    hasSchedule={hasSchedule}
                />
            ) : null }
        </div>
    )
}

export default Body;
// Schedule.js
import { useEffect, useState } from "react";

const Schedule = ({ isScheduleVisible, setIsScheduleVisible, setHasSchedule, hasSchedule }) => {
    const [schedules, setSchedules] = useState([]);
    const [schedule, setSchedule] = useState('');

    const month = isScheduleVisible[0];
    const day = isScheduleVisible.slice(2);

    useEffect(() => {
        const schedulesDataStr = localStorage.getItem(`schedules_${month}_${day}`);

        if (schedulesDataStr) {
            const scheduleDataArray = schedulesDataStr.split(',');

            setSchedules(scheduleDataArray);
        }
    }, []);

    useEffect(() => {
        if (hasSchedule?.length) {
            localStorage.setItem(`has-schedule`, JSON.stringify(hasSchedule));
        }
    }, [hasSchedule]);

    useEffect(() => {
        if (schedules.length) {
            localStorage.setItem(`schedules_${month}_${day}`, schedules);
        }
    }, [schedules]);
    
    const handleModalVisible = () => {
        setIsScheduleVisible(0);
    }

    const handleSchedule = (e) => {
        setSchedule(e.target.value);
    }

    const onCreateNewSchedule = () => {
        if (schedule.length) {
            setSchedules((prevSchedules) => [...prevSchedules, schedule]);
            setSchedule('');

            setHasSchedule([...(hasSchedule), {
                month: parseInt(month), 
                day: parseInt(day)
            }]);
            
        }
    }
    
    return (
        <div className='Schedule'>
            <h1>{month}월 {day}일 일정</h1>
            <button
                className='modal-close-button'
                onClick={handleModalVisible}
            >
                X
            </button>
            <div className='new-schedule-wrapper'>
                <input
                    type='text'
                    className='new-schedule-input' 
                    required
                    value={schedule}
                    onChange={handleSchedule}
                />
                <button 
                    className='new-schedule-button'
                    onClick={onCreateNewSchedule}
                >
                    일정 추가하기
                </button>
            </div>
            <ul className='schedule-item-wrapper'>
                {schedules.map((scheduleItem, idx) => (
                    <li
                        className='schedule-item'
                        key={idx}    
                    >
                        {scheduleItem}
                    </li>
                ))}
            </ul>
        </div>
    )
}

export default Schedule;
// App.css
* {
    text-align: center;
    margin-left: auto;
    margin-right: auto;
}

/* HEADER */
.Header {
    display: flex;
    justify-content: space-between;
    background-color: rgb(240, 235, 235);
    border-radius: 20px;
    border: 1.5px solid rgb(72, 68, 68);
    max-width: 600px;
}

.left-col {
    display: flex;
    margin: 10px;
}

.current-month {
    font-size: 24px;
    font-weight: 600;
    color: rgb(72, 68, 68);
}

.current-year {
    align-self: flex-end;
    margin-left: 5px;
    font-size: 16px;
    font-weight: 400;
    color: rgb(72, 68, 68);
}

.right-col {
    margin: 10px;
    align-self: center;
}

.month-button {
    border-radius: 10px;
    border: 1.5px solid rgb(72, 68, 68);
    margin-left: 10px;
    font-size: 24px;
    cursor: pointer;
}

/* DAYS */
.days-list {
    display: flex;
    max-width: 600px;
    padding: 0;
}

.days {
    list-style: none;
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 10px;
    width: 70px;
    background-color: rgb(240, 235, 235);
    margin-left: auto;
    margin-right: auto;
}

.days_0 {
    margin-left: 0;
}

.days_6 {
    margin-right: 0;
}

/* BODY */
.Body {
    position: relative;
}

.row {
    max-width: 600px;
    display: flex;
    margin-bottom: 10px;
}

.day-disabled,
.day-valid,
.day-selected {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 10px;
    width: 70px;
    height: 70px;
    background-color: white;
    cursor: pointer;
}

.day_0 {
    margin-left: 0;
}

.day_6 {
    margin-right: 0;
}

.day-disabled {
    border: 1.5px solid rgb(208, 203, 203);
}

.day-selected {
    background-color: rgb(213, 205, 205);
}

.text-not-valid {
    color: rgb(208, 203, 203);
}

.text-not-valid,
.text-valid {
    display: flex;
    justify-content: center;
    padding: 0;
    margin-left: 7px;
    margin-top: 7px;
    width: 25px;
    height: 25px;
}

.text-schedule-on {
    border: 1.5px solid  rgb(72, 68, 68);
    border-radius: 50%;
    background-color: rgb(213, 205, 205);
    color: black;
    padding: 0;
    margin-left: 7px;
    margin-top: 7px;
    width: 25px;
    height: 25px;
    display: flex;
    justify-content: center;
}

/* SCHEDULE */
.Schedule {
    position: absolute;
    top: 50px;
    left: calc(50% - 150px);
    width: 300px;
    height: 300px;
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 20px;
    background-color: white;
    opacity: 90%;
}

.Schedule h1 {
    margin-top: 40px;
}

.modal-close-button {
    border: transparent;
    background-color: white;
    font-size: 20px;
    font-weight: 700;
    cursor: pointer;
    position: absolute;
    top: 20px;
    right: 20px;
}

.new-schedule-wrapper {
    display: flex;
}

.new-schedule-button {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 8px;
    height: 30px;
    margin: 0px;
    margin-right: auto;
    cursor: pointer;
    font-weight: 700;
}

.new-schedule-input {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 8px;
    height: 27px;
    padding: 0;
    margin-right: 5px;
    margin-left: auto;
}

.schedule-item-wrapper {
    width: 265px;
    height: 100px;
    display: flex;
    flex-direction: column;
    padding: 0;
    padding-top: 10px;
    overflow: auto;
}

.schedule-item-wrapper::-webkit-scrollbar {
    border: 1.5px solid  rgb(72, 68, 68);
    background-color: rgb(229, 222, 222);
    border-radius: 10px;
}

.schedule-item-wrapper::-webkit-scrollbar-thumb {
    border: 1.5px solid  rgb(72, 68, 68);
    background-color: rgb(95, 92, 92);
    border-radius: 10px;
}

.schedule-item {
    list-style: none;
    font-weight: 550;
    margin-bottom: 10px;
}

세부적인 구현 사항에 대해서 설명해보자면,

우선, Body 컴포넌트 내부에 hasSchedule이라는 일정을 추가한 날짜들을 저장하기 위한 state를 만들었다.

 

그래서 hasSchedule statesetHasScheduleSchedule 컴포넌트

props로 전달을 해주었고, 일정을 추가하는 함수onCreateNewSchedule 함수 실행 시 schedules 배열에 추가 후 hasSchedule에도 새로운 스케줄을 추가하도록 설정하였다.

 

이후 로컬 스토리지hasSchedule state를 저장해주니 원하는 결과가 나오지 않았다.

이는 state비동기적으로 업데이트되기 때문이었다.

 

따라서 이에 대한 해결 방법으로 useEffect 훅을 사용하였다.

useEffect 훅을 사용해서 hasSchedule이 변경될 때마다 로컬 스토리지hasSchedule 값을 저장하도록 설정하여 비동기적으로 업데이트되는 문제는 해결되었다.

 

그리고 Body 컴포넌트에서 날을 나타내는 span 요소의 className 지정 도중 문제가 발생하였는데, 가장 먼저 발생한 문제는 format 함수를 사용해서 뽑아낸 day과 monthhasSchedule에서 뽑아낸 요소의 day와 month비교하려고 하였는데 잘 되지 않았다.

 

이는 정말 기본적인 작동인데 간과하기 쉬운 부분이었다.

format 함수의 반환 값string 값이고 hasSchedule의 month와 day는 int로 설정해주어 비교를 위해서는 format 함수의 반환 값에 대해 형 변환을 시켜주어야 했다.

따라서 parseInt를 사용해서 이 또한 해결하였다.

 

마지막으로, 처음 달력이 화면에 렌더링 될스케줄이 있는 날들로컬 스토리지에서 불러와서 화면에 표시해주려고 하였는데, 이는 Body 컴포넌트 측에서 처리를 해주었다.

 

useEffect 훅을 사용해서 로컬 스토리지에 있는 hasSchedule 값을 불러오고 이를 hasSchedule state에 저장하였다.

그 이유는 화면 새로고침hasSchedule state 값다시 빈 배열로 reset 되는데 로컬 스토리지에 저장된 일정이 있는 날들을 불러와서 hasSchedule state를 지정해주어야 이전에 추가한 일정이 있는 날들을 알 수 있기 때문이다.

 

모달창 외부를 클릭한 경우 모달창이 닫히도록 설정해보자!

사용자들의 편의를 위해 이 기능을 추가로 넣어보고자 했다.

 

다음은 모달창 외부 클릭 시 모달창이 닫히도록 구현 완료한 코드이다.

// Schedule.js
import { useEffect, useRef, useState } from "react";

const Schedule = ({ isScheduleVisible, setIsScheduleVisible, setHasSchedule, hasSchedule }) => {
    const [schedules, setSchedules] = useState([]);
    const [schedule, setSchedule] = useState('');

    const month = isScheduleVisible[0];
    const day = isScheduleVisible.slice(2);

    const modalOutside = useRef();

    useEffect(() => {
        const schedulesDataStr = localStorage.getItem(`schedules_${month}_${day}`);

        if (schedulesDataStr) {
            const scheduleDataArray = schedulesDataStr.split(',');

            setSchedules(scheduleDataArray);
        }
    }, []);

    useEffect(() => {
        if (hasSchedule?.length) {
            localStorage.setItem(`has-schedule`, JSON.stringify(hasSchedule));
        }
    }, [hasSchedule]);

    useEffect(() => {
        if (schedules.length) {
            localStorage.setItem(`schedules_${month}_${day}`, schedules);
        }
    }, [schedules]);
    
    const handleModalVisibleOutside = (e) => {
        if (modalOutside.current === e.target) {
            setIsScheduleVisible(0);
        }
    }

    const handleModalVisible = () => {
        setIsScheduleVisible(0);
    }

    const handleSchedule = (e) => {
        setSchedule(e.target.value);
    }

    const onCreateNewSchedule = () => {
        if (schedule.length) {
            setSchedules((prevSchedules) => [...prevSchedules, schedule]);
            setSchedule('');

            setHasSchedule([...(hasSchedule), {
                month: parseInt(month), 
                day: parseInt(day)
            }]);
            
        }
    }
    
    return (
        <div className='schedule-background' 
            ref={modalOutside} 
            onClick={handleModalVisibleOutside}
        >
            <div className='Schedule'>
                <h1>{month}월 {day}일 일정</h1>
                <button
                    className='modal-close-button'
                    onClick={handleModalVisible}
                >
                    X
                </button>
                <div className='new-schedule-wrapper'>
                    <input
                        type='text'
                        className='new-schedule-input' 
                        required
                        value={schedule}
                        onChange={handleSchedule}
                    />
                    <button 
                        className='new-schedule-button'
                        onClick={onCreateNewSchedule}
                    >
                        일정 추가하기
                    </button>
                </div>
                <ul className='schedule-item-wrapper'>
                    {schedules.map((scheduleItem, idx) => (
                        <li
                            className='schedule-item'
                            key={idx}    
                        >
                            {scheduleItem}
                        </li>
                    ))}
                </ul>
            </div>
        </div>
    )
}

export default Schedule;
// App.css
* {
    text-align: center;
    margin-left: auto;
    margin-right: auto;
}

/* HEADER */
.Header {
    display: flex;
    justify-content: space-between;
    background-color: rgb(240, 235, 235);
    border-radius: 20px;
    border: 1.5px solid rgb(72, 68, 68);
    max-width: 600px;
}

.left-col {
    display: flex;
    margin: 10px;
}

.current-month {
    font-size: 24px;
    font-weight: 600;
    color: rgb(72, 68, 68);
}

.current-year {
    align-self: flex-end;
    margin-left: 5px;
    font-size: 16px;
    font-weight: 400;
    color: rgb(72, 68, 68);
}

.right-col {
    margin: 10px;
    align-self: center;
}

.month-button {
    border-radius: 10px;
    border: 1.5px solid rgb(72, 68, 68);
    margin-left: 10px;
    font-size: 24px;
    cursor: pointer;
}

/* DAYS */
.days-list {
    display: flex;
    max-width: 600px;
    padding: 0;
}

.days {
    list-style: none;
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 10px;
    width: 70px;
    background-color: rgb(240, 235, 235);
    margin-left: auto;
    margin-right: auto;
}

.days_0 {
    margin-left: 0;
}

.days_6 {
    margin-right: 0;
}

/* BODY */
.Body {
    position: relative;
}

.row {
    max-width: 600px;
    display: flex;
    margin-bottom: 10px;
}

.day-disabled,
.day-valid,
.day-selected {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 10px;
    width: 70px;
    height: 70px;
    background-color: white;
    cursor: pointer;
}

.day_0 {
    margin-left: 0;
}

.day_6 {
    margin-right: 0;
}

.day-disabled {
    border: 1.5px solid rgb(208, 203, 203);
}

.day-selected {
    background-color: rgb(213, 205, 205);
}

.text-not-valid {
    color: rgb(208, 203, 203);
}

.text-not-valid,
.text-valid {
    display: flex;
    justify-content: center;
    padding: 0;
    margin-left: 7px;
    margin-top: 7px;
    width: 25px;
    height: 25px;
}

.text-schedule-on {
    border: 1.5px solid  rgb(72, 68, 68);
    border-radius: 50%;
    background-color: rgb(213, 205, 205);
    color: black;
    padding: 0;
    margin-left: 7px;
    margin-top: 7px;
    width: 25px;
    height: 25px;
    display: flex;
    justify-content: center;
}

/* SCHEDULE */
.schedule-background {
    position: absolute;
    top: 0;
    width: 100%;
    height: 100%;
}

.Schedule {
    position: absolute;
    top: 50px;
    left: calc(50% - 150px);
    width: 300px;
    height: 300px;
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 20px;
    background-color: white;
    opacity: 90%;
}

.Schedule h1 {
    margin-top: 40px;
}

.modal-close-button {
    border: transparent;
    background-color: white;
    font-size: 20px;
    font-weight: 700;
    cursor: pointer;
    position: absolute;
    top: 20px;
    right: 20px;
}

.new-schedule-wrapper {
    display: flex;
}

.new-schedule-button {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 8px;
    height: 30px;
    margin: 0px;
    margin-right: auto;
    cursor: pointer;
    font-weight: 700;
}

.new-schedule-input {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 8px;
    height: 27px;
    padding: 0;
    margin-right: 5px;
    margin-left: auto;
}

.schedule-item-wrapper {
    width: 265px;
    height: 100px;
    display: flex;
    flex-direction: column;
    padding: 0;
    padding-top: 10px;
    overflow: auto;
}

.schedule-item-wrapper::-webkit-scrollbar {
    border: 1.5px solid  rgb(72, 68, 68);
    background-color: rgb(229, 222, 222);
    border-radius: 10px;
}

.schedule-item-wrapper::-webkit-scrollbar-thumb {
    border: 1.5px solid  rgb(72, 68, 68);
    background-color: rgb(95, 92, 92);
    border-radius: 10px;
}

.schedule-item {
    list-style: none;
    font-weight: 550;
    margin-bottom: 10px;
}

이번 기능은 useRef 훅을 사용하여 구현하였다.

모달창 외부 영역을 나타내는 modalOutside라는 ref 객체를 만들어주었다.

 

그리고 모달창 컴포넌트Schedule 요소 외부컨테이너div 태그를 하나 추가해주었는데, 이때 ref 속성과 onClick 속성을 추가해주었다.

이는 모달창 외부를 나타내는데, 이 부분을 클릭하게 되면 모달창을 닫을 수 있도록 설정한 것이다.

이 과정을 handleModalVisibleOutside라는 함수가 실행한다.

ref 객체에 들어있는 값현재 클릭으로 활성화시킨 부분같다면 모달창을 닫도록 설정하였다.

 

다른 달에 속한 날일정 추가하지 못하도록 막자!

이제 거의 다 완성했다.

 

현재 속한 달이 아니더라도 화면에 보이는 날들이 있다.

현재 상황에서는 4월 달력을 보여주는 상태에서도 3월 마지막 며칠5월 첫 주의 며칠일정을 추가할 수 있도록 설정되어있다.

나는 4월에 속한 날들이 아니라면 일정을 추가하지 못하도록 바꾸고 싶다.

 

아래 코드는 위 사항들을 고려해서 수정한 코드이다.

// Body.js
/* eslint-disable no-loop-func */
import { endOfMonth, endOfWeek, format, isSameMonth, isSameDay, startOfMonth, startOfWeek, addDays, parse } from "date-fns";
import Schedule from "./Schedule";

import { useEffect, useState } from "react";

const Body = ({ currentMonth, selectedDate }) => {
    const monthStart = startOfMonth(currentMonth);
    const monthEnd = endOfMonth(monthStart);
    const startDate = startOfWeek(monthStart);
    const endDate = endOfWeek(monthEnd);

    const rows = [];
    let days = [];
    let day = startDate;
    let formattedDate = '';

    const [isScheduleVisible, setIsScheduleVisible] = useState(0);
    const [hasSchedule, setHasSchedule] = useState([]);

    const handleScheduleVisible = (e) => {
        setIsScheduleVisible(e.currentTarget.getAttribute('value'));
    }

    useEffect(() => {
        const scheduleData = JSON.parse(localStorage.getItem('has-schedule'));

        if (scheduleData?.length) {
            setHasSchedule(scheduleData);
        }
    }, []);

    while (day <= endDate) {
        for (let i = 0; i < 7; i++) {
            formattedDate = format(day, 'd');

            days.push(
                <div
                    className={`day-${
                        !isSameMonth(day, monthStart)
                        ? 'disabled'
                        : isSameDay(day, selectedDate)
                        ? 'selected'
                        : format(currentMonth, 'M') !== format(day, 'M')
                        ? 'not-valid'
                        : 'valid'
                    } day_${i}`}
                    key={day}
                    value={format(day, 'M/d')}
                    onClick={handleScheduleVisible}
                >
                    <span
                        className={[
                            format(currentMonth, 'M') !== format(day, 'M') 
                            ? 'text-not-valid' 
                            : 'text-valid',
                            Array.isArray(hasSchedule) && hasSchedule?.find((scheduleItem) => scheduleItem?.month === parseInt(format(day, 'M')) && scheduleItem?.day === parseInt(format(day, 'd')))
                            ? 'text-schedule-on'
                            : 'text-schedule-off'
                        ].join(' ')}
                    >
                        {formattedDate}
                    </span>
                </div>
            )

            day = addDays(day, 1);
        }
        
        rows.push(
            <div className='row' key={day}>
                {days}
            </div>
        )
        days = [];
    }

    return (
        <div className='Body'>
            {rows}
            { isScheduleVisible !== 0 ? (
                <Schedule
                    isScheduleVisible={isScheduleVisible} 
                    setIsScheduleVisible={setIsScheduleVisible}
                    setHasSchedule={setHasSchedule}
                    hasSchedule={hasSchedule}
                    currentMonth={currentMonth}
                />
            ) : null }
        </div>
    )
}

export default Body;
// Schedule.js
import { useEffect, useRef, useState } from "react";

import format from "date-fns/format";

const Schedule = ({ isScheduleVisible, setIsScheduleVisible, setHasSchedule, hasSchedule, currentMonth }) => {
    const [schedules, setSchedules] = useState([]);
    const [schedule, setSchedule] = useState('');

    const month = isScheduleVisible[0];
    const day = isScheduleVisible.slice(2);

    const modalOutside = useRef();

    useEffect(() => {
        const schedulesDataStr = localStorage.getItem(`schedules_${month}_${day}`);

        if (schedulesDataStr) {
            const scheduleDataArray = schedulesDataStr.split(',');

            setSchedules(scheduleDataArray);
        }
    }, []);

    useEffect(() => {
        if (hasSchedule?.length) {
            localStorage.setItem(`has-schedule`, JSON.stringify(hasSchedule));
        }
    }, [hasSchedule]);

    useEffect(() => {
        if (schedules.length) {
            localStorage.setItem(`schedules_${month}_${day}`, schedules);
        }
    }, [schedules]);
    
    const handleModalVisibleOutside = (e) => {
        if (modalOutside.current === e.target) {
            setIsScheduleVisible(0);
        }
    }

    const handleModalVisible = () => {
        setIsScheduleVisible(0);
    }

    const handleSchedule = (e) => {
        setSchedule(e.target.value);
    }

    const onCreateNewSchedule = () => {
        if (format(currentMonth, 'M') !== month) {
            alert(`현재는 ${format(currentMonth, 'M')}월입니다. ${format(currentMonth, 'M')}월에 속한 날에 대한 일정만 추가할 수 있습니다.`);
            return;
        }

        if (schedule.length) {
            setSchedules((prevSchedules) => [...prevSchedules, schedule]);
            setSchedule('');

            setHasSchedule([...(hasSchedule), {
                month: parseInt(month), 
                day: parseInt(day)
            }]);
            
        }
    }
    
    return (
        <div className='schedule-background' 
            ref={modalOutside} 
            onClick={handleModalVisibleOutside}
        >
            <div className='Schedule'>
                <h1>{month}월 {day}일 일정</h1>
                <button
                    className='modal-close-button'
                    onClick={handleModalVisible}
                >
                    X
                </button>
                <div className='new-schedule-wrapper'>
                    <input
                        type='text'
                        className='new-schedule-input' 
                        required
                        value={schedule}
                        onChange={handleSchedule}
                    />
                    <button 
                        className='new-schedule-button'
                        onClick={onCreateNewSchedule}
                    >
                        일정 추가하기
                    </button>
                </div>
                <ul className='schedule-item-wrapper'>
                    {schedules.map((scheduleItem, idx) => (
                        <li
                            className='schedule-item'
                            key={idx}    
                        >
                            {scheduleItem}
                        </li>
                    ))}
                </ul>
            </div>
        </div>
    )
}

export default Schedule;

현재 달력에 보여주는 달currentMonthBody 컴포넌트에서 Schedule 컴포넌트에 추가로 props로 전달하였다.

 

이를 통해 일정을 추가하는 Schedule 컴포넌트onCreateNewSchedule 함수에서 현재 달내가 선택한 날의 달을 비교하여 같다면 일정 추가가 가능하도록 설정하였다.

 

최종 코드는 다음과 같다.

// App.js
import './App.css';

import Calendar from './Calendar';

const App = () => {
  return (
    <div className='App'>
      <Calendar />
    </div>
  )
}

export default App;
// Calendar.js
import { useState } from 'react';

import Header from './Header';
import Days from './Days';
import Body from './Body';

const Calendar = () => {
    const [selectedDate, setSelectedDate] = useState(new Date());
    const [currentMonth, setCurrentMonth] = useState(new Date());

    return (
        <div className='calendar'>
            <Header
                currentMonth={currentMonth}
                setCurrentMonth={setCurrentMonth}
            />
            <Days />
            <Body 
                currentMonth={currentMonth}
                selectedDate={selectedDate}
            />
        </div>
    )
}

export default Calendar;
// Header.js
import { addMonths, format, subMonths } from "date-fns";

const Header = ({ currentMonth, setCurrentMonth }) => {
    const prevMonth = () => {
        setCurrentMonth(subMonths(currentMonth, 1));
    }
    const nextMonth = () => {
        setCurrentMonth(addMonths(currentMonth, 1));
    }

    return (
        <div className='Header'>
            <div className='left-col'>
                <div className='current-month'>
                    {format(currentMonth, 'M')}월
                </div>
                <div className='current-year'>
                    {format(currentMonth, 'yyyy')}
                </div>
            </div>
            <div className='right-col'>
                <button 
                    className='month-button prev-month-button'
                    onClick={prevMonth}
                >
                    {'<'}
                </button>
                <button 
                    className='month-button next-month-button'
                    onClick={nextMonth}
                >
                    {'>'}
                </button>
            </div>
        </div>
    )
}

export default Header;
// Days.js
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

const Days = () => {
    return (
        <div className='Days'>
            <ul className='days-list'>
                {days.map((day, idx) => (
                    <li 
                        key={idx}
                        className={`days days_${idx}`}
                    >
                        {day}
                    </li>
                ))}
            </ul>
        </div>
    )
}

export default Days;
// Body.js
/* eslint-disable no-loop-func */
import { endOfMonth, endOfWeek, format, isSameMonth, isSameDay, startOfMonth, startOfWeek, addDays, parse } from "date-fns";
import Schedule from "./Schedule";

import { useEffect, useState } from "react";

const Body = ({ currentMonth, selectedDate }) => {
    const monthStart = startOfMonth(currentMonth);
    const monthEnd = endOfMonth(monthStart);
    const startDate = startOfWeek(monthStart);
    const endDate = endOfWeek(monthEnd);

    const rows = [];
    let days = [];
    let day = startDate;
    let formattedDate = '';

    const [isScheduleVisible, setIsScheduleVisible] = useState(0);
    const [hasSchedule, setHasSchedule] = useState([]);

    const handleScheduleVisible = (e) => {
        setIsScheduleVisible(e.currentTarget.getAttribute('value'));
    }

    useEffect(() => {
        const scheduleData = JSON.parse(localStorage.getItem('has-schedule'));

        if (scheduleData?.length) {
            setHasSchedule(scheduleData);
        }
    }, []);

    while (day <= endDate) {
        for (let i = 0; i < 7; i++) {
            formattedDate = format(day, 'd');

            days.push(
                <div
                    className={`day-${
                        !isSameMonth(day, monthStart)
                        ? 'disabled'
                        : isSameDay(day, selectedDate)
                        ? 'selected'
                        : format(currentMonth, 'M') !== format(day, 'M')
                        ? 'not-valid'
                        : 'valid'
                    } day_${i}`}
                    key={day}
                    value={format(day, 'M/d')}
                    onClick={handleScheduleVisible}
                >
                    <span
                        className={[
                            format(currentMonth, 'M') !== format(day, 'M') 
                            ? 'text-not-valid' 
                            : 'text-valid',
                            Array.isArray(hasSchedule) && hasSchedule?.find((scheduleItem) => scheduleItem?.month === parseInt(format(day, 'M')) && scheduleItem?.day === parseInt(format(day, 'd')))
                            ? 'text-schedule-on'
                            : 'text-schedule-off'
                        ].join(' ')}
                    >
                        {formattedDate}
                    </span>
                </div>
            )

            day = addDays(day, 1);
        }
        
        rows.push(
            <div className='row' key={day}>
                {days}
            </div>
        )
        days = [];
    }

    return (
        <div className='Body'>
            {rows}
            { isScheduleVisible !== 0 ? (
                <Schedule
                    isScheduleVisible={isScheduleVisible} 
                    setIsScheduleVisible={setIsScheduleVisible}
                    setHasSchedule={setHasSchedule}
                    hasSchedule={hasSchedule}
                    currentMonth={currentMonth}
                />
            ) : null }
        </div>
    )
}

export default Body;
// Schedule.js
import { useEffect, useRef, useState } from "react";

import format from "date-fns/format";

const Schedule = ({ isScheduleVisible, setIsScheduleVisible, setHasSchedule, hasSchedule, currentMonth }) => {
    const [schedules, setSchedules] = useState([]);
    const [schedule, setSchedule] = useState('');

    const month = isScheduleVisible[0];
    const day = isScheduleVisible.slice(2);

    const modalOutside = useRef();

    useEffect(() => {
        const schedulesDataStr = localStorage.getItem(`schedules_${month}_${day}`);

        if (schedulesDataStr) {
            const scheduleDataArray = schedulesDataStr.split(',');

            setSchedules(scheduleDataArray);
        }
    }, []);

    useEffect(() => {
        if (hasSchedule?.length) {
            localStorage.setItem(`has-schedule`, JSON.stringify(hasSchedule));
        }
    }, [hasSchedule]);

    useEffect(() => {
        if (schedules.length) {
            localStorage.setItem(`schedules_${month}_${day}`, schedules);
        }
    }, [schedules]);
    
    const handleModalVisibleOutside = (e) => {
        if (modalOutside.current === e.target) {
            setIsScheduleVisible(0);
        }
    }

    const handleModalVisible = () => {
        setIsScheduleVisible(0);
    }

    const handleSchedule = (e) => {
        setSchedule(e.target.value);
    }

    const onCreateNewSchedule = () => {
        if (format(currentMonth, 'M') !== month) {
            alert(`현재는 ${format(currentMonth, 'M')}월입니다. ${format(currentMonth, 'M')}월에 속한 날에 대한 일정만 추가할 수 있습니다.`);
            return;
        }

        if (schedule.length) {
            setSchedules((prevSchedules) => [...prevSchedules, schedule]);
            setSchedule('');

            setHasSchedule([...(hasSchedule), {
                month: parseInt(month), 
                day: parseInt(day)
            }]);
            
        }
    }
    
    return (
        <div className='schedule-background' 
            ref={modalOutside} 
            onClick={handleModalVisibleOutside}
        >
            <div className='Schedule'>
                <h1>{month}월 {day}일 일정</h1>
                <button
                    className='modal-close-button'
                    onClick={handleModalVisible}
                >
                    X
                </button>
                <div className='new-schedule-wrapper'>
                    <input
                        type='text'
                        className='new-schedule-input' 
                        required
                        value={schedule}
                        onChange={handleSchedule}
                    />
                    <button 
                        className='new-schedule-button'
                        onClick={onCreateNewSchedule}
                    >
                        일정 추가하기
                    </button>
                </div>
                <ul className='schedule-item-wrapper'>
                    {schedules.map((scheduleItem, idx) => (
                        <li
                            className='schedule-item'
                            key={idx}    
                        >
                            {scheduleItem}
                        </li>
                    ))}
                </ul>
            </div>
        </div>
    )
}

export default Schedule;
// App.css
* {
    text-align: center;
    margin-left: auto;
    margin-right: auto;
}

/* HEADER */
.Header {
    display: flex;
    justify-content: space-between;
    background-color: rgb(240, 235, 235);
    border-radius: 20px;
    border: 1.5px solid rgb(72, 68, 68);
    max-width: 600px;
}

.left-col {
    display: flex;
    margin: 10px;
}

.current-month {
    font-size: 24px;
    font-weight: 600;
    color: rgb(72, 68, 68);
}

.current-year {
    align-self: flex-end;
    margin-left: 5px;
    font-size: 16px;
    font-weight: 400;
    color: rgb(72, 68, 68);
}

.right-col {
    margin: 10px;
    align-self: center;
}

.month-button {
    border-radius: 10px;
    border: 1.5px solid rgb(72, 68, 68);
    margin-left: 10px;
    font-size: 24px;
    cursor: pointer;
}

/* DAYS */
.days-list {
    display: flex;
    max-width: 600px;
    padding: 0;
}

.days {
    list-style: none;
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 10px;
    width: 70px;
    background-color: rgb(240, 235, 235);
    margin-left: auto;
    margin-right: auto;
}

.days_0 {
    margin-left: 0;
}

.days_6 {
    margin-right: 0;
}

/* BODY */
.Body {
    position: relative;
}

.row {
    max-width: 600px;
    display: flex;
    margin-bottom: 10px;
}

.day-disabled,
.day-valid,
.day-selected {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 10px;
    width: 70px;
    height: 70px;
    background-color: white;
    cursor: pointer;
}

.day_0 {
    margin-left: 0;
}

.day_6 {
    margin-right: 0;
}

.day-disabled {
    border: 1.5px solid rgb(208, 203, 203);
}

.day-selected {
    background-color: rgb(213, 205, 205);
}

.text-not-valid {
    color: rgb(208, 203, 203);
}

.text-not-valid,
.text-valid {
    display: flex;
    justify-content: center;
    padding: 0;
    margin-left: 7px;
    margin-top: 7px;
    width: 25px;
    height: 25px;
}

.text-schedule-on {
    border: 1.5px solid  rgb(72, 68, 68);
    border-radius: 50%;
    background-color: rgb(213, 205, 205);
    color: black;
    padding: 0;
    margin-left: 7px;
    margin-top: 7px;
    width: 25px;
    height: 25px;
    display: flex;
    justify-content: center;
}

/* SCHEDULE */
.schedule-background {
    position: absolute;
    top: 0;
    width: 100%;
    height: 100%;
}

.Schedule {
    position: absolute;
    top: 50px;
    left: calc(50% - 150px);
    width: 300px;
    height: 300px;
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 20px;
    background-color: white;
    opacity: 90%;
}

.Schedule h1 {
    margin-top: 40px;
}

.modal-close-button {
    border: transparent;
    background-color: white;
    font-size: 20px;
    font-weight: 700;
    cursor: pointer;
    position: absolute;
    top: 20px;
    right: 20px;
}

.new-schedule-wrapper {
    display: flex;
}

.new-schedule-button {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 8px;
    height: 30px;
    margin: 0px;
    margin-right: auto;
    cursor: pointer;
    font-weight: 700;
}

.new-schedule-input {
    border: 1.5px solid rgb(72, 68, 68);
    border-radius: 8px;
    height: 27px;
    padding: 0;
    margin-right: 5px;
    margin-left: auto;
}

.schedule-item-wrapper {
    width: 265px;
    height: 100px;
    display: flex;
    flex-direction: column;
    padding: 0;
    padding-top: 10px;
    overflow: auto;
}

.schedule-item-wrapper::-webkit-scrollbar {
    border: 1.5px solid  rgb(72, 68, 68);
    background-color: rgb(229, 222, 222);
    border-radius: 10px;
}

.schedule-item-wrapper::-webkit-scrollbar-thumb {
    border: 1.5px solid  rgb(72, 68, 68);
    background-color: rgb(95, 92, 92);
    border-radius: 10px;
}

.schedule-item {
    list-style: none;
    font-weight: 550;
    margin-bottom: 10px;
}

 

컴포넌트 트리 형태로 살펴보자!

Calendar 컴포넌트현재 날짜를 입력받아 currentMonth stateselectedDate state에 저장한다.

currentMonth state는 달력에서 현재 보여줄 달을 나타내고, selectedDate state현재 날짜를 보여주기 때문에 구분해서 나타내었다.

 

Calendar 컴포넌트에서 Headers 컴포넌트에는 propscurrentMonth statesetCurrentMonth를 전달하였는데, Headers 컴포넌트달을 보여주는 컴포넌트이기 때문에 currentMonth가 필요하고, 이전/이후 달로 이동할 수 있는 버튼이 있기 때문에 setCurrentMonth가 필요하다.

 

Days 컴포넌트는 일~토 요일을 정적으로 보여주는 역할만 수행하기 때문에 별다르게 props로 받아야 하는 요소가 없다.

 

Body 컴포넌트현재 보여줄 달현재 날짜를 알아야 화면에 표시할 수 있기 때문에 currentMonthselectedDateprops로 받는다.

 

Schedule 컴포넌트일정 모달창을 보여줄 때 해당 일정이 속한 달과 날로컬 스토리지에 저장하는 과정이 있기 때문에 isScheduleVisibleprops로 전달받는다.

그리고 이 모달창을 없애는 작업을 위해 setIsScheduleVisible 또한 props로 받게 된다.

hasSchedulesetHasSchedule의 경우, 일정이 있는 날들을 화면에 표시해주기 위해서 존재하는데, 이를 Schedule 컴포넌트에서 일정 추가 시 변경을 해주기 위해서 props로 받게 된다.

마지막으로 currentMonth 또한 props로 받게 되는데, 이는 바로 위에서 구현한 기능인, 현재 달력에 보여주는 달일정을 추가하려고 하는 달일치하는지 판단을 위한 상태이다.

 

포트 번호가 다르면 로컬 스토리지에 저장된 내용다른가?

이번에 구현을 하면서 처음 알게 된 사실인데, 포트 번호가 다르면 로컬 스토리지 내부 내용도 구분해서 들어간다는 것이다.

 

3000번 포트로 작업을 하다가 3001번 포트를 열어서 작업을 했는데, 로컬 스토리지다른 내용이 들어가 있어서 알게 되었다.

 

참고 자료

https://gmldbd94.tistory.com/57

https://velog.io/@bellecode20/localStorage에-state-저장하기

https://codingbroker.tistory.com/66

https://studyingych.tistory.com/28

https://stackoverflow.com/questions/51084725/how-does-object-spread-work-if-it-is-not-an-iterable

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries

https://velog.io/@tiatiahwang/Week3-모달창-배경-클릭시-닫기