🏂

月カレンダーを自作してみた(TypeScript x Next.js x Jest)

2023/08/20に公開

先日思い立ってやりました一日アプリ制作チャレンジでは、ラジオ体操のスタンプカードを模したUIを作りました。今回はこの月カレンダーUIについて説明します。

一日アプリ制作チャレンジに関する記事は以下になります👇
https://zenn.dev/snowwshiro/articles/4f4230650bc36d

作ったアプリはこちらです👉 スタンプぽん

結論・要約

月カレンダーを表すには、1週7日x5週=35日分のデータを用意すればよい。以下手順で作成できる。

  • その月の1日が何曜日か判定する
  • 曜日毎に遡り、35日分データの初日を割り出す
  • 初日から35日分を生成する

最初に

これまでカレンダーといえば入力フォームでDatePickerのようなライブラリに頼ることがほとんどで、自作したことはありませんでした。
一日でアプリを作りきるという制約がある中で、いつもなら「カレンダー JavaScript 自作」のように検索して記事を探すところですが、今回はまずはカレンダーを観察してみました。
その中で、「月のカレンダーは1週7日x5週=35日分のマスで表現できるんだー」という事に(今更)気づき、ひとまず2023年8月を以下のように作成しました。

src/components/CalendarBody.tsx
import { CalendarCol } from '@/components/CalendarCol'
import dayjs from 'dayjs'

type DataProps = {
  id: number
  date: string
}[]

const data: DataProps = [
  { id: 1, date: '2023-07-31' },
  { id: 2, date: '2023-08-01' },
  { id: 3, date: '2023-08-02' },
  { id: 4, date: '2023-08-03' },
  { id: 5, date: '2023-08-04' },
  { id: 6, date: '2023-08-05' },
  { id: 7, date: '2023-08-06' },
  { id: 8, date: '2023-08-07' },
  { id: 9, date: '2023-08-08' },
  { id: 10, date: '2023-08-09' },
  { id: 11, date: '2023-08-10' },
  { id: 12, date: '2023-08-11' },
  { id: 13, date: '2023-08-12' },
  { id: 14, date: '2023-08-13' },
  { id: 15, date: '2023-08-14' },
  { id: 16, date: '2023-08-15' },
  { id: 17, date: '2023-08-16' },
  { id: 18, date: '2023-08-17' },
  { id: 19, date: '2023-08-18' },
  { id: 20, date: '2023-08-19' },
  { id: 21, date: '2023-08-20' },
  { id: 22, date: '2023-08-21' },
  { id: 23, date: '2023-08-22' },
  { id: 24, date: '2023-08-23' },
  { id: 25, date: '2023-08-24' },
  { id: 26, date: '2023-08-25' },
  { id: 27, date: '2023-08-26' },
  { id: 28, date: '2023-08-27' },
  { id: 29, date: '2023-08-28' },
  { id: 30, date: '2023-08-29' },
  { id: 31, date: '2023-08-30' },
  { id: 32, date: '2023-08-31' },
  { id: 33, date: '2023-09-01' },
  { id: 34, date: '2023-09-02' },
  { id: 35, date: '2023-09-03' },
]

const MONTH = '8'

export const CalendarBody = () => {
  const month = (date: string) => {
    return dayjs(date).format('M')
  }
  const day = (date: string) => {
    return dayjs(date).format('D')
  }

  return (
    <div className="grid grid-cols-7 grid-rows-5">
      {data.map(
        (item) =>
          (month(item.date) === MONTH && (
            <div key={item.id}>
              <CalendarCol day={day(item.date)} date={item.date} />
            </div>
          )) || <div key={item.id}></div>
      )}
    </div>
  )
}
src/components/CalendarCol.tsx
'use client'

import { useState } from 'react'

import dayjs from 'dayjs'

import toast from 'react-hot-toast'

import { Stamp } from '@/components/Stamp'

type Props = {
  day: string
  date: string
}

export const CalendarCol = ({ day, date }: Props) => {
  const [stamped, setStamped] = useState(false)
  const today = dayjs().format('YYYY-MM-DD')

  const stampAction = () => {
    if (today >= date) {
      setStamped(true)
    } else {
      toast.error('未来の日付は押せません')
    }
  }

  return (
    <div className="grid grid-cols-3 grid-rows-3 cursor-pointer" onClick={() => stampAction()}>
      <span className="text-sm text-gray-500 row-start-1 row-end-2 col-start-1 col-end-2 self-center justify-self-center">
        {day}
      </span>
      <div className="row-start-1 row-end-5 col-start-1 col-end-5 self-center justify-self-center">
        <Stamp stamped={stamped} />
      </div>
    </div>
  )
}

35日分のデータについて、8月であれば日付を表示し、7月・9月は何も表示しないようにしています。
一旦表示は出来ましたが、これだと9月になっても8月のままとなってしまうので、Dataを関数で生成するようにします。

関数の作成

以下のような関数を作りました。

src/utils/createCalendarArray.ts
import dayjs from "dayjs";
import ja from 'dayjs/locale/ja';

dayjs.locale(ja);

type DayOfWeek = "月" | "火" | "水" | "木" | "金" | "土" | "日";

const startDay = (dayOfWeek: DayOfWeek) => {
  switch (dayOfWeek) {
    case "月":
      return 0;
    case "火":
      return 1;
    case "水":
      return 2;
    case "木":
      return 3;
    case "金":
      return 4;
    case "土":
      return 5;
    case "日":
      return 6;
  }
}

export const createCalendarArray = (
  year: string = dayjs().format('YYYY'),
  month: string = dayjs().format('MM')
) => {
  const firstDay = dayjs(`${year}-${month}-01`) // その年月の1日
  const firstDayOfMonth = firstDay.format('dd') as DayOfWeek // firstDayの曜日
  const firstDayOfCalendar = firstDay.subtract(startDay(firstDayOfMonth), "day") // firstDayの曜日からカレンダーの1日目を計算
  return [...Array(35)].map((e, index)=> {
    const obj = { id: index + 1, date: firstDayOfCalendar.add(index, "day").format("YYYY-MM-DD") };
    return obj
  }) // 35個の要素を持つ配列を作成
}

作成したい年月の1日が何曜日か判定し、曜日毎に遡り、35日分データの初日を割り出します。月曜日始まりのカレンダーであれば、1日が水曜日の場合2日遡る、といった形です。
あとは、割り出した初日から35日分を生成するだけです。

テストの作成

上の関数について、以下のようなテストを作成しました。

src/utils/createCalendarArray.test.ts
import dayjs from "dayjs";

import { createCalendarArray } from "./createCalendarArray";

describe('通常年の場合', () => {
  test('配列が35個の要素を持つ', () => {
    expect(createCalendarArray("2023", "8")).toHaveLength(35)
  });
  test('引数がない場合、今月になる', () => {
    expect(createCalendarArray().forEach((e) => {
      if (e.date === dayjs().format("YYYY-MM-DD")) {
        expect(e.date).toMatch(dayjs().format("YYYY-MM-DD"))
      }
    }))
  })
  test('8月の場合、1日と31日が含まれる', () => {
    createCalendarArray("2023", "8").forEach((e) => {
      if (e.date === "2023-08-01") {
        expect(e).toHaveProperty('date', '2023-08-01')
      } else if (e.date === "2023-08-31") {
        expect(e).toHaveProperty('date', '2023-08-31')
      }
    })
  })
})

describe('うるう年の場合', () => {
  test('2月に29日が存在する', () => {
    createCalendarArray("2028", "2").forEach((e) => {
      if (e.date === "2028-02-29") {
        expect(e).toHaveProperty('date', '2028-02-29')
      }
    })
  })
})

カレンダーといえば気になるのはうるう年の場合かと思います。直近のうるう年は2028年ということで、その年の2月についてテストを書きましたが、ちゃんと29日がありました。

終わりに

こちらの関数に書き換えたコミットは以下になります。
https://github.com/snowwshiro/stamp_card_app/commit/fe668133406132c1e8b98e242f44216296a6b755

コード全体も以下にて公開しています。
https://github.com/snowwshiro/stamp_card_app

次回は前月・翌月と、当月以外に移動できるようにする予定です。

Discussion