JavaScriptでカレンダーを作る in ES2019

# 2022年になってもカレンダーを一から作る
みなさんカレンダー作ってますか？
「今どき、Google カレンダーでも埋め込んどけや」という声が聞こえてきそうですが、実際どうなんでしょう？

もしかしたら誰にも求められていない可能性がありますが、過去に投稿したカレンダー作成に関する記事のアップデート版としてこちらに色々と残しておくので、必要になったら使ってみてください。

ちなみに今回はロジックとマークアップを完全に分けました。
そのうえ、DOMではなくテンプレートリテラルで組み立てています。

ここ数年でフロントエンドでもテンプレートを使うのが一般的になってきましたし、そのほうが勝手が良さそうということと、処理中にDOMをいじるのがかなり面倒に感じたものですから。

なお、過去の投稿は下記のものです。

https://qiita.com/shingorow/items/6085693cb039f073c5e2

## まずはJavaScriptから
JavaScriptのコードは以下のとおりです。
コードのあとに補足を入れていきます。

```javascript
const settings = {
  calendarId: 'calendar',
  currentYear: null, // fullYear形式で入力
  currentMonth: null, // Indexではなく実際の月でOK
  isStartAtSunday: true // falseにすると月曜はじまり
}

// 一週間は7日間あります、みんな知ってるよね
const daysAWeek = 7

// 曜日の配列を作成。あとで曜日表記とHTMLクラスに使う
const days = [
  { en: 'sun', ja: '日' },
  { en: 'mon', ja: '月' },
  { en: 'tue', ja: '火' },
  { en: 'wed', ja: '水' },
  { en: 'thu', ja: '木' },
  { en: 'fri', ja: '金' },
  { en: 'sat', ja: '土' }
]

// 日曜と月曜のどちらでスタートするかのフラグ
const startOffset = settings.isStartAtSunday ? 0 : 1

// 今日の日付を取得
const current = new Date()

// カレンダーの表示年月を取得
const currentYear = settings.currentYear ?? current.getFullYear()
const currentMonth = settings.currentMonth
  ? settings.currentMonth - 1
  : current.getMonth()

current.setFullYear(currentYear)
current.setMonth(currentMonth)

// 前後の月のインデックスを設定
const prevMonth = current.getMonth() - 1
const nextMonth = current.getMonth() + 1

// 月初の情報取得
current.setDate(1)
const firstDay = current.getDay()

// 月末情報の取得
current.setMonth(nextMonth)
current.setDate(0)
const lastDate = current.getDate()

const currentDateList = [...Array(lastDate + firstDay).keys()]
  .slice(firstDay)
  .map((key) => {
    const data = {
      year: currentYear,
      month: currentMonth + 1,
      date: key - firstDay + 1,
      day: key % daysAWeek,
      target: 'current'
    }

    return data
  })

// 前月の情報取得
const prevDateObject = new Date(current.getTime())
prevDateObject.setDate(0)
const prevYear = prevDateObject.getFullYear()
const prevLastDay = prevDateObject.getDay()
const prevLastDate = prevDateObject.getDate()
prevDateObject.setDate(1)
const prevFirstDay = prevDateObject.getDay()

const prevDateList = [...Array(prevLastDate + prevFirstDay).keys()]
  .slice(-1 * (prevLastDay + 1 - startOffset))
  .map((key) => {
    const data = {
      year: prevYear,
      month: prevMonth + 1,
      date: key - prevFirstDay + 1,
      day: key % daysAWeek,
      target: 'prev'
    }

    return data
  })

// 翌月の情報取得
const nextDateObject = new Date(current.getTime())
nextDateObject.setMonth(nextMonth)
nextDateObject.setDate(1)
const nextYear = nextDateObject.getFullYear()
const nextFirstDay = nextDateObject.getDay()

const nextDateList = [...Array(daysAWeek + startOffset).keys()]
  .slice(nextFirstDay)
  .map((key) => {
    const data = {
      year: nextYear,
      month: nextMonth + 1,
      date: key - nextFirstDay + 1,
      day: key % 7,
      target: 'next'
    }

    return data
  })

// 当月と前後をがっちゃんこ
const calendarList = [...prevDateList, ...currentDateList, ...nextDateList]

// 週毎のリストに分ける
const weekLength = Math.ceil(calendarList.length / daysAWeek)
const weekList = [...Array(weekLength).keys()].map((i) => {
  return calendarList.slice(i * daysAWeek, (i + 1) * daysAWeek)
})

// 曜日表示のHTMLを組み立てる
const dayLabelList = []
days.forEach((day, i) => {
  const id = (daysAWeek + i - startOffset) % daysAWeek
  dayLabelList[id] = `<th class="${day.en}">${day.ja}</th>`
})

const daysMarkup = dayLabelList.join('\n')
const daysWrapper = `<tr>${daysMarkup}</tr>`

let dateMarkup = ''
for (const week of weekList) {
  let buf = ''
  for (const date of week) {
    buf += `<td class="date-item ${date.year}-${String(date.month).padStart(
      2,
      '0'
    )}-${String(date.date).padStart(2, '0')} ${days[date.day].en} ${date.target
      }">${date.date}</td>`
  }
  dateMarkup += `<tr>${buf}</tr>\n`
}

document.addEventListener('DOMContentLoaded', () => {
  const calendar = document.getElementById(settings.calendarId)
  const template = document.getElementById('template-calendar')
  const calendarNode = template.content.cloneNode(true)

  calendarNode.querySelector('caption').innerHTML = `${currentYear}年${currentMonth + 1
    }月`
  calendarNode.querySelector('thead').innerHTML = daysWrapper
  calendarNode.querySelector('tbody').innerHTML = dateMarkup

  calendar.appendChild(calendarNode)
})
```

ところどことで

```javascript
[...Array(lastDate + firstDay).keys()]
    .slice(firstDay)
```
といった感じの記述が出てきますが、曜日と日付を別々に処理するよりも、合算してループ内で分離するのが楽に感じたのでこんな処理を行なっています。

もっといい方法があれば教えてほしいなぁ。

## 設定を最小限に
以前の記事では日付や、週毎のラッパータグも設定でできるようにしてありましたが、もうそのへんはテンプレートの役割だよねということで今回は省きました。

これにより、ロジック部分はだいぶスッキリしたように思います。

実例をCodePenに掲載したので、そちらも合わせて見ていただければと。

<p class="codepen" data-height="300" data-default-tab="js,result" data-slug-hash="yLPbMJz" data-user="shingorow" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/shingorow/pen/yLPbMJz">
  Calendar in ES2019</a> by Shingo Matsui (<a href="https://codepen.io/shingorow">@shingorow</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

## おわりに
今回、ロジックとマークアップを分けたことでレイアウトの自由度がかなりましたんじゃないでしょうかね。

四角いカレンダーではなく、一直線のタイムライン的な表示も結構簡単に実現できそうです。

令和になって、自分でカレンダーを実装する人がどれだけいるかわかりませんが、お役に立てれば嬉しい限りです。