[TIL] Vue3/Vitest - Calendar Implemetation

jeongjwon·2024년 1월 23일
0

Vue

목록 보기
17/19

참고
https://www.youtube.com/watch?v=MvP6W6oi9Do






Calendar implementation with Vue3 and TDD(Vitest)

Vite 프로젝트를 설정하고, TDD를 통해 테스트 주도 개발 하며 캘린더를 구현해본다.
애플리케이션의 코드를 작성하기 전에 테스트를 작성하고 이를 통과시키면서 코드를 개발하는 접근 방식이다.
Vite와 TDD 를 함께 사용하면서 애플리케이션의 안정성을 향상시키고 유지보수를 용이하게 만들 수 있다.

src > components > __tests__ 폴더 내 _test_ 폴더를 만들어 __.test.js 파일을 통해 테스트를 수행할 수 있다.

  1. describe : 테스트 스위트를 그룹화하여 특정 컴포넌트, 모듈 또는 기능에 대한 테스트를 묶어 관리할 떄 사용한다.
  2. test : 특정 테스트 케이스를 정의하는데 사용된다.
  3. expect : 테스트 결과를 기대하는데 사용된다.

LocalDate implementation

LocalDate 클래스는 날짜 및 시간의 offset 을 계산하도록 하는 기능이다.
1. contructor(value)
: Date 객체를 받아 LocalDate 인스턴스를 생성하여 주어진 Date 객체를 클래스의 value 프로퍼티에 할당
2. year, month, date Getter 메서드
: value 프로퍼티에 연도, 월, 일을 추출 / pad 함수를 이용하여 연도를 4자리로, 월과 일을 각각 2자리로 포맷
3. ymdText Getter 메서드
: year, month, date 를 사용하여 날짜를 'yyyy-mm-dd'형식의 문자열로 반환
4. weekOffset Getter 메서드
: value 의 요일을 반환 (0: 일요일, 1: 월요일, ..., 6: 토요일)
5. minus(delta, unit), plus(delta, unit) 메서드
: delta와 unit 인수를 받아 날짜를 더하거나 빼는 메서드로 shiftDate 함수를 사용
6. equals(other) 메서드
: 다른 LocalDate 객체와 비교하여 두 객체의 연도, 월, 일이 같은지에 대한 여부 확인



**LocalDate.fromYmd 함수** : 연도, 월, 일을 받아 Date 객체를 생성하여 LocalDate 인스턴스를 반환 (단, monthsms 1~12의 값)

//index.js
/**
 *
 * @param {number} num
 * @param {number} length
 */
const pad = (num, length) => {
  return ('0000' + num).slice(-length)
}
const MILLIS = {
  day: 1 * 24 * 60 * 60 * 1000,
  week: 7 * 1 * 24 * 60 * 60 * 1000
}
/**
 *
 * @param {LocalDate} date
 * @param {number} delta
 * @param {string} unit
 */
const shiftDate = (date, delta, unit) => {
  const millis = delta * MILLIS[unit]
  const curMillis = date.value.getTime()
  return new LocalDate(new Date(curMillis + millis))
}
export class LocalDate {
  /**
   * @type {Date} date instance
   */
  value
  /**
   *
   * @param {Date} value
   */
  constructor(value) {
    this.value = value
  }
  get year() {
    return pad(this.value.getFullYear(), 4) //It is number
  }
  get month() {
    return pad(this.value.getMonth() + 1, 2) //number
  }
  get date() {
    return pad(this.value.getDate(), 2) //number
  }
  get ymdText() {
    const { year, month, date } = this
    return `${year}-${month}-${date}`
  }
  get weekOffset() {
    return this.value.getDay()
  }
  /**
   *
   * @param {number} delta
   * @param {string} unit  - 'day', 'week', ..
   */
  minus(delta, unit) {
    return shiftDate(this, -1 * delta, unit)
  }
  plus(delta, unit) {
    return shiftDate(this, delta, unit)
  }
  /**
   *
   * @param {LocalDate} date
   */
  equals(other) {
    return (
      other && other.year === this.year && other.month === this.month && other.date === this.date
    )
  }
}

/**
 *
 * @param {Number} year
 * @param {Number} month 1 ~ 12(not monthIndex)
 * @param {*} date 1 ~ 28, 30, 31
 * @returns
 */
LocalDate.fromYmd = (year, month, date) => {
  const value = new Date(year, month - 1, date)
  return new LocalDate(value)
}


//LocalDate.test.js
import { describe, test, expect } from 'vitest'
import { LocalDate } from '..'

describe('class LocalDate', () => {
  test('format date', () => {
    const d0 = LocalDate.fromYmd(2023, 6, 9)
    expect(d0.year).toBe('2023')
    expect(d0.month).toBe('06')
    expect(d0.date).toBe('09')
    expect(d0.ymdText).toBe('2023-06-09')
  })
  test('week offset of a date', () => {
    /**
     * 2023 - 06
     *
     * S  M  T  W  T  F  Sat
     *                   27   5
     *          31 01 02  3   5,6
     *  4  5  6  7  8  9 10   6
     * 11                17   6
     * 18                24   6
     * 25             30 01   6,7
     * ---------------
     *  0  1  2  3  4  5  6  offset in a week (sunday: 0, saturday: 6)
     */
    const d0 = LocalDate.fromYmd(2023, 6, 9)
    expect(d0.weekOffset).toBe(5)
  })
  test('minus', () => {
    const fri = LocalDate.fromYmd(2023, 6, 9)
    const thu = fri.minus(1, 'day')
    expect(thu.date).toBe('08')
    expect(thu).toStrictEqual(LocalDate.fromYmd(2023, 6, 8))
    expect(fri.minus(7, 'day')).toStrictEqual(LocalDate.fromYmd(2023, 6, 2))
  })

  test('plus', () => {
    const fri = LocalDate.fromYmd(2023, 6, 9)
    expect(fri.plus(7, 'day')).toStrictEqual(LocalDate.fromYmd(2023, 6, 16))
  })
})

LocalDate.test.js 파일을 통해 테스트를 진행한다.











Week implementation

Week 클래스 는 특정 주(Week)의 정보를 다룬다.
1. contructor(leadingDate)
: 특정 주의 시작 날짜(leadingDate)를 받아 Week 인스턴스 생성
: leadingDate 는 주의 첫번째 날짜로 사용되며 date 프로퍼티에 할당
2. dayAt(weekOffset) 메서드
: 특정 주의 weekOffset 에 해당하는 날짜를 반환 , 0부터 6까지의 값(0: 일요일, 6: 토요일)
3. days() 메서드
: 현재 주에 속한 7일간의 날짜를 배열로 반환, dayAt 메서드를 사용하여 7일간의 날짜를 구함
4. prev(), next() 메서드
: 이전 주와 다음 주의 Week 인스턴스 반환


**Week.fromLocalDate 함수** : LocalDate 객체를 받아 해당 날짜가 속한 주의 첫번째 날인 일요일을 찾아 Week 인스턴스 반환 : date.weekOffset 을 사용하여 현재 날짜가 몇 일째인지 확인후, 해당 주의 일요일을 찾음

export class Week {
  /**
   * @type {LocalDate} leading date (sunday ?)
   */
  day
  /**
   *
   * @param {LocalDate} leadingDate
   */
  constructor(leadingDate) {
    this.date = leadingDate
  }
  /**
   *
   * @param {number} weekOffset 0(sunday), 6(saturday)
   * @returns
   */
  dayAt(weekOffset) {
    return this.date.plus(weekOffset, 'day')
  }
  /**
   *
   * @returns days of this week
   */
  days() {
    const sevenDays = []
    for (let offset = 0; offset < 7; offset++) {
      const day = this.date.plus(offset, 'day')
      sevenDays.push(day)
    }
    return sevenDays
  }
  prev() {
    const leadingDate = this.date.minus(7, 'day')
    return new Week(leadingDate)
  }
  next() {
    const leadingDate = this.date.plus(7, 'day')
    return new Week(leadingDate)
  }
}
/**
 *
 * @param {LocalDate} date
 * @returns
 */
Week.fromLocalDate = (date) => {
  const offset = date.weekOffset  //현재 날짜의 요일을 구함
  const sunday = date.minus(offset, 'day')  //offset 만큼 minus -> 일요일은 0이므로 
  return new Week(sunday)
}


//Week.test.js
import { describe, test, expect } from 'vitest'
import { LocalDate, Week } from '..'

describe('class Week', () => {
  test('capturing week range from a date', () => {
    /**
     * 2023 - 06
     *
     * S  M  T  W  T  F  Sat
     *                   27   5
     * 28 29 30 31 01 02  3   5,6
     *  4  5  6  7  8  9 10   6
     * 11                17   6
     * 18                24   6
     * 25             30 01   6,7
     * ---------------
     *  0  1  2  3  4  5  6  offset in a week (sunday: 0, saturday: 6)
     */
    const d7 = LocalDate.fromYmd(2023, 6, 7) //[4, ..., 10]
    const w0 = Week.fromLocalDate(d7)
    expect(w0.dayAt(0)).toStrictEqual(LocalDate.fromYmd(2023, 6, 4))
    expect(w0.dayAt(6)).toStrictEqual(LocalDate.fromYmd(2023, 6, 10))
  })
  test('prev next week', () => {
    const d7 = LocalDate.fromYmd(2023, 6, 7) //[4, ..., 10]
    const w0 = Week.fromLocalDate(d7)
    const prevWeek = w0.prev()
    expect(prevWeek.dayAt(0)).toStrictEqual(LocalDate.fromYmd(2023, 5, 28))

    const nextWeek = w0.next()
    expect(nextWeek.dayAt(0)).toStrictEqual(LocalDate.fromYmd(2023, 6, 11))
  })
})

Week.test.js 파일을 통해 테스트를 진행한다.











Calendar implementation

Calendar 클래스는 월 단위로 주(Week)를 관리하는 클래스로 특정 월에 해당하는 주들을 포함하고, 해당 월의 첫 주와 마지막 주, 년도 및 월 정보를 제공한다.
1. constructor(weeks)
: Week 인스턴스들(특정 월에 해당하는 주)의 배열을 받아 Calendar 인스턴스 생성
2. firstWeek, lastWeek Getter 메서드
: 첫번째 주와 마지막 주에 해당하는 Week 인스턴스 반환
3. yearText, monthText Getter 메서드
: firstWeek 를 기준으로 해당 월의 년도와 월을 텍스트 형식으로 반환
4. getWeeks() 메서드
: 해당 Calendar 인스턴스에 속한 모든 주(Weeks)를 반환
5. prevMonth(), nextMonth() 메서드
: 이전 월 또는 다음 월에 해당하는 Calendar 인스턴스 반환
: firstWeek.prev() 또는 lastWeek.next() 를 사용하여 계산
6. contiansDate(date) 메서드
: 주어진 날짜(date)가 현재 Calendar 에 속하는지에 대한 여부 확인
: 쉽게 말해 날짜의 월이 현재 월과 동일한지를 체크하여 판단
7. isToday(date) 메서드
: 주어진 날짜(date)가 현재 날짜인지 확인, LocalDate 클래스의 equals 메서드를 사용하여 현재 날짜와 비교


Calendar.fromYm
: 년도와 월을 받아 해당 월에 대한 Calendar 인스턴스 생성
: 해당 월의 첫 날에 대한 LocalDate 와 Week 인스턴스를 생성하고, 이를 기반으로 6주간의 Week 인스턴스를 배열에 추가하여 Calendar 를 생성



export class Calendar {
  /**
   * @type {Week[]} weeks of a month
   */
  weeks
  /**
   *
   * @param {Week[]} weeks
   */
  constructor(weeks) {
    this.weeks = weeks
  }
  get firstWeek() {
    return this.weeks[0]
  }
  get lastWeek() {
    return this.weeks[this.weeks.length - 1]
  }
  get yearText() {
    return this.firstWeek.dayAt(0).year
  }
  get monthText() {
    return this.firstWeek.dayAt(6).month //check last dat of a week
  }
  getWeeks() {
    return this.weeks
  }
  prevMonth() {
    // any day of prev month
    const anyDay = this.firstWeek.prev().dayAt(0)
    const year = Number.parseInt(anyDay.year) // "2023" -> 2023
    const month = Number.parseInt(anyDay.month)

    return Calendar.fromYm(year, month)
  }
  nextMonth() {
    // any day of next month
    const anyDay = this.lastWeek.next().dayAt(0)
    const year = Number.parseInt(anyDay.year) // "2023" -> 2023
    const month = Number.parseInt(anyDay.month)

    return Calendar.fromYm(year, month)
  }
  /**
   *
   * @param {LocalDate} date
   */
  containsDate(date) {
    return this.monthText === date.month
  }
  /**
   *
   * @param {LocalDate} date
   */
  isToday(date) {
    const now = new LocalDate(new Date())
    return now.equals(date)
  }
}
/**
 *
 * @param {number} year
 * @param {number} month 1 ~ 12(not month index)
 * @returns
 */
Calendar.fromYm = (year, month) => {
  const leadingDate = LocalDate.fromYmd(year, month, 1)
  const leadingWeek = Week.fromLocalDate(leadingDate)
  const weeks = []
  let ref = leadingWeek
  while (weeks.length < 6) {
    weeks.push(ref)
    ref = ref.next()
  }
  return new Calendar(weeks)
}


//Calendar.test.js
import { describe, test, expect } from 'vitest'
import { Calendar } from '..'

describe('class Calendar', () => {
  test('init', () => {
    const m6 = Calendar.fromYm(2023, 6)
    expect(m6.yearText).toBe('2023')
    expect(m6.monthText).toBe('06')

    const weeks = m6.getWeeks()
    expect(weeks.length).toBe(6)
  })
  test('prev next month', () => {
    const m01 = Calendar.fromYm(2023, 1)
    const m12 = m01.prevMonth()
    expect(m12.yearText).toBe('2022')
    expect(m12.monthText).toBe('12')

    const m02 = m01.nextMonth()
    expect(m02.yearText).toBe('2023')
    expect(m02.monthText).toBe('02')
  })
})

Calendar.test.js 파일을 통해 테스트를 진행한다.






테스트 결과

npm run test:unit 명령어를 통해 프로젝트에서 유닛 테스트를 실행한다.

"scripts": {
   "test:unit": "vitest",
}

package.json 파일에서 scripts 섹션을 확인하여 구체적인 설정을 찾아볼 수 있는데, test:unit 명령어가 vitest 이므로 npm run vitest 인 것이나 다름없다.


LocalDate.test.js 에서 4가지 테스트와
Week.test.js 에서 2가지 테스트,
Calendar.test.js 에서 2가지 테스트로
총 3개의 파일과 8개의 테스트가 성공적으로 완료되었다.











Creating Vue Calendar Component

MonthCalendar.vue 컴포넌트를 통해 생성한 클래스들을 이용해 달력을 구현한다.

<template>
  <div class="calendar">
    <nav>
      <h3>
        <span>{{ cal.yearText }} - {{ cal.monthText }}</span>
      </h3>
      <div class="navs">
        <button @click="prevMonth()">prev</button>
        <button @click="nextMonth()">next</button>
      </div>
    </nav>
    <section class="dow">
      <div v-for="day in days" :key="day" class="day">{{ day }}</div>
    </section>
    <section class="body">
      <div v-for="week in cal.getWeeks()" :key="week" class="week">
        <div
          v-for="date in week.days()"
          :key="date"
          class="cell"
          :class="{ oob: !cal.containsDate(date), today: cal.isToday(date) }"
        >
          <span class="date">{{ date.date }}</span>
        </div>
      </div>
    </section>
  </div>
</template>
<script setup>
import { Calendar } from './'
import { ref } from 'vue'
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']

const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1

//console.log(currentYear, currentMonth)

let cal = ref(Calendar.fromYm(currentYear, currentMonth))

const prevMonth = () => {
  console.log('[prev]')
  cal.value = cal.value.prevMonth()
}
const nextMonth = () => {
  console.log('[next]')
  cal.value = cal.value.nextMonth()
}
</script>
<style lang="scss" scoped>
$border-color: #efefef;
.calendar {
  display: flex;
  flex-direction: column;
  height: 600px;
  nav {
    h3 {
      margin: 0;
    }
  }
  section {
    &.dow {
      display: flex;

      .day {
        flex: 1 1 calc(100% / 7);
        width: calc(100% / 7);
        border-top: 1px solid $border-color;
        border-right: 1px solid $border-color;
        border-bottom: 1px solid $border-color;
        padding: 12px;
        text-align: center;
        &:first-child {
          border-left: 1px solid $border-color;
          color: #a4001f;
        }
        &:last-child {
          color: #0064bb;
        }
      }
    }
    &.body {
      flex: 1 1 auto;
      display: flex;
      flex-direction: column;
      .week {
        display: flex;
        flex-direction: row;
        flex: 1 1 auto;
        height: calc(100% / 6);
        &:first-child {
          .cell {
            /* border-top: 1px solid $border-color; */
          }
        }

        .cell {
          flex: 1 1 calc(100% / 7);
          width: calc(100% / 7);
          position: relative;
          border-bottom: 1px solid $border-color;
          border-right: 1px solid $border-color;
          &:first-child {
            border-left: 1px solid $border-color;
          }
          &.oob {
            //out of bound
            .date {
              background-color: transparent;
              color: #ccc;
            }
          }
          &.today {
            .date {
              background-color: #ffe5e9;
              color: #a4001f;
            }
          }
          .date {
            position: absolute;
            top: 6px;
            right: 6px;
            font-size: 12px;
            background-color: #dcefff;
            color: #0064bb;
            border-radius: 20px;
            display: flex;
            width: 28px;
            height: 28px;
            align-items: center;
            justify-content: center;
          }
        }
      }
    }
  }
}
</style>
  1. cal 변수를 오늘 날짜를 통해 ref 로 Calendar 인스턴스를 생성한다.
  2. prev, next 버튼을 통해 Calendar 클래스 내 prevMonth(), nextMonth() 메서드를 실행할 수 있다.
  3. 요일을 알 수 있도록 section 태그 dow 클래스에 반복문을 통해 days 변수를 렌더링한다.
  4. section 태그 body 클래스에 cal 변수의 getWeeks() 메서드를 통해 해당 Calendar 인스턴스에 속한 모든 주를 배열로 반환한다.
  5. 받아온 week는 해당 Week 인스턴스는 다시 days() 메서드를 통해 현재 주에 속한 7일 간의 날짜를 배열로 반환하여 각각의 날짜를 date 로 저장하여 렌더링한다.
  6. cell 클래스에서는 날짜(date)가 해당 Calendar 인스턴스에 속하지 않은 날짜일 경우 (6주의 배열을 나타내긴 하지만, 그 중에서도 1월이 아닌 12월이나 2월을 나타내는 날짜가 있을 경우)에 out of bound 의 약자로 oob 클래스가 동적으로 적용된다. scss style 을 적용시켜 해당 달일 경우에는 파란색으로 표시되게 한다.
  7. 또한 해당 날짜가(date) 현재 날짜와 동일한 날일 경우에 today 클래스가 적용되어 빨간색으로 표시되게 한다.





실행화면


2024 - 01 만 2023 - 01 로 오류가 나는 부분이 있다.






마치며

달력 라이브러리를 사용하지 않고 직접 하나하나 메서드와 클래스를 통해 달력을 구현해서 뿌듯하다. 그리고 TDD 를 통해 클래스 하나씩 테스트를 하는 것을 통해 프론트엔드를 하면서 실직적으로 작성하고 통과하는 과정을 배워서 유지보수하기 쉬울 것 같다는 생각을 했다.






0개의 댓글