月カレンダーを自作してみた(TypeScript x Next.js x Jest)
先日思い立ってやりました一日アプリ制作チャレンジでは、ラジオ体操のスタンプカードを模したUIを作りました。今回はこの月カレンダーUIについて説明します。
一日アプリ制作チャレンジに関する記事は以下になります👇
作ったアプリはこちらです👉 スタンプぽん
結論・要約
月カレンダーを表すには、1週7日x5週=35日分のデータを用意すればよい。以下手順で作成できる。
- その月の1日が何曜日か判定する
- 曜日毎に遡り、35日分データの初日を割り出す
- 初日から35日分を生成する
最初に
これまでカレンダーといえば入力フォームでDatePickerのようなライブラリに頼ることがほとんどで、自作したことはありませんでした。
一日でアプリを作りきるという制約がある中で、いつもなら「カレンダー JavaScript 自作」のように検索して記事を探すところですが、今回はまずはカレンダーを観察してみました。
その中で、「月のカレンダーは1週7日x5週=35日分のマスで表現できるんだー」という事に(今更)気づき、ひとまず2023年8月を以下のように作成しました。
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>
)
}
'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を関数で生成するようにします。
関数の作成
以下のような関数を作りました。
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日分を生成するだけです。
テストの作成
上の関数について、以下のようなテストを作成しました。
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日がありました。
終わりに
こちらの関数に書き換えたコミットは以下になります。
コード全体も以下にて公開しています。
次回は前月・翌月と、当月以外に移動できるようにする予定です。
Discussion